Improve crop editor and copy publish output to dist

Add resize handles, zoom, view brightness, and a multi-frame still
strip. Keep the crop box on mouse-up, and copy AmiReel.exe to dist.
This commit is contained in:
2026-08-15 22:59:20 +02:00
parent 323324d713
commit e635b708a2
10 changed files with 801 additions and 96 deletions
+1
View File
@@ -4,6 +4,7 @@
[Dd]ebug/
[Rr]elease/
publish/
dist/
# IDE / tooling
.vs/
@@ -57,6 +57,19 @@ public class SourceCropSelectionTests
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToInsets_KeepsTheDraggedWidthAndHeight()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(3840, 2160, 768, 432, 100, 50, 200, 120);
// Assert
Assert.AreEqual(500, insets.Left);
Assert.AreEqual(250, insets.Top);
Assert.AreEqual(1000, 3840 - insets.Left - insets.Right);
Assert.AreEqual(600, 2160 - insets.Top - insets.Bottom);
}
[TestMethod]
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
{
@@ -97,4 +110,106 @@ public class SourceCropSelectionTests
Assert.AreEqual(0, x);
Assert.AreEqual(60, y);
}
[TestMethod]
public void HitTest_OnNorthWestHandle_ReturnsNorthWest()
{
// Act
CropHit hit = SourceCropSelection.HitTest(20, 30, 80, 40, 20, 30, 12);
// Assert
Assert.AreEqual(CropHit.NorthWest, hit);
}
[TestMethod]
public void HitTest_InsideRectangle_ReturnsMove()
{
// Act
CropHit hit = SourceCropSelection.HitTest(20, 30, 80, 40, 50, 50, 12);
// Assert
Assert.AreEqual(CropHit.Move, hit);
}
[TestMethod]
public void HitTest_OutsideRectangle_ReturnsNone()
{
// Act
CropHit hit = SourceCropSelection.HitTest(20, 30, 80, 40, 2, 2, 12);
// Assert
Assert.AreEqual(CropHit.None, hit);
}
[TestMethod]
public void AdjustRect_Move_KeepsSizeAndClamps()
{
// Act
(double x, double y, double width, double height) =
SourceCropSelection.AdjustRect(10, 10, 40, 20, CropHit.Move, 100, -4, 80, 60);
// Assert
Assert.AreEqual(40, x);
Assert.AreEqual(6, y);
Assert.AreEqual(40, width);
Assert.AreEqual(20, height);
}
[TestMethod]
public void AdjustRect_EastHandle_ChangesWidthOnly()
{
// Act
(double x, double y, double width, double height) =
SourceCropSelection.AdjustRect(10, 10, 40, 20, CropHit.East, 8, 5, 80, 60);
// Assert
Assert.AreEqual(10, x);
Assert.AreEqual(10, y);
Assert.AreEqual(48, width);
Assert.AreEqual(20, height);
}
[TestMethod]
public void AdjustRect_WestHandle_MovesLeftEdge()
{
// Act
(double x, _, double width, _) =
SourceCropSelection.AdjustRect(20, 10, 40, 20, CropHit.West, 6, 0, 80, 60);
// Assert
Assert.AreEqual(26, x);
Assert.AreEqual(34, width);
}
[TestMethod]
public void Nudge_MovesBoxWithoutChangingSize()
{
// Arrange
PixelInsets start = new(10, 20, 8, 12);
// Act
PixelInsets moved = SourceCropSelection.Nudge(start, 100, 80, 4, 0);
// Assert
Assert.AreEqual(14, moved.Left);
Assert.AreEqual(16, moved.Right);
Assert.AreEqual(8, moved.Top);
Assert.AreEqual(12, moved.Bottom);
}
[TestMethod]
public void NudgeEdge_GrowsRightSide()
{
// Arrange
PixelInsets start = new(10, 20, 8, 12);
// Act
PixelInsets grown = SourceCropSelection.NudgeEdge(start, 100, 80, 0, 0, -4, 0);
// Assert
Assert.AreEqual(10, grown.Left);
Assert.AreEqual(16, grown.Right);
Assert.AreEqual(8, grown.Top);
Assert.AreEqual(12, grown.Bottom);
}
}
@@ -483,6 +483,39 @@ public class ShortsPipelineTests
CollectionAssert.Contains(args, "in.mp4");
}
[TestMethod]
public void CropPreviewOffsets_SpreadsFramesAcrossDuration()
{
// Act
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(30, 5);
// Assert
Assert.AreEqual(5, offsets.Count);
Assert.AreEqual(0, offsets[0]);
Assert.AreEqual(29.85, offsets[^1]);
Assert.AreEqual(14.925, offsets[2]);
}
[TestMethod]
public void CropPreviewOffsets_WithZeroDuration_ReturnsStartOnly()
{
// Act
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(0);
// Assert
CollectionAssert.AreEqual(new[] { 0d }, offsets.ToArray());
}
[TestMethod]
public void FormatPreviewOffsetLabel_UnderOneHour_UsesMinutesAndSeconds()
{
// Act
string label = ShortsPipeline.FormatPreviewOffsetLabel(75);
// Assert
Assert.AreEqual("1:15", label);
}
[TestMethod]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{
+71 -8
View File
@@ -9,7 +9,7 @@
<StackPanel Spacing="10" MinWidth="640">
<TextBlock x:Name="StatusText"
Text="Drag on the frame to keep that region. The rest is cropped away."
Text="Drag a keep-region, then resize it with the handles. Zoom and brightness are for viewing only."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<ProgressBar x:Name="LoadProgress"
@@ -17,32 +17,95 @@
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Loading source frame" />
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Slider x:Name="ZoomSlider"
Grid.Column="0"
Header="Zoom"
Minimum="1"
Maximum="4"
StepFrequency="0.25"
TickFrequency="0.5"
SnapsTo="StepValues"
Value="1"
ValueChanged="ZoomSlider_ValueChanged"
AutomationProperties.Name="View zoom"
AutomationProperties.AutomationId="CropZoomSlider" />
<Slider x:Name="BrightnessSlider"
Grid.Column="1"
Header="View brightness"
Minimum="-0.7"
Maximum="0.7"
StepFrequency="0.05"
TickFrequency="0.35"
SnapsTo="StepValues"
Value="0"
ValueChanged="BrightnessSlider_ValueChanged"
AutomationProperties.Name="View brightness"
AutomationProperties.AutomationId="CropBrightnessSlider" />
</Grid>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled"
HorizontalScrollMode="Auto"
VerticalScrollMode="Disabled"
AutomationProperties.Name="Crop preview frames">
<StackPanel x:Name="FrameStrip" Orientation="Horizontal" Spacing="8" />
</ScrollViewer>
<Border Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid x:Name="CropHost"
<ScrollViewer x:Name="CropScroller"
Height="420"
ZoomMode="Disabled"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
HorizontalScrollMode="Auto"
VerticalScrollMode="Auto"
SizeChanged="CropScroller_SizeChanged"
PointerWheelChanged="CropScroller_PointerWheelChanged"
AutomationProperties.Name="Source crop view">
<Grid x:Name="ImageSurface"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Background="Transparent"
PointerPressed="CropHost_PointerPressed"
PointerMoved="CropHost_PointerMoved"
PointerReleased="CropHost_PointerReleased"
PointerCaptureLost="CropHost_PointerCaptureLost"
SizeChanged="CropHost_SizeChanged"
IsTabStop="True"
UseSystemFocusVisuals="True"
PointerPressed="ImageSurface_PointerPressed"
PointerMoved="ImageSurface_PointerMoved"
PointerReleased="ImageSurface_PointerReleased"
PointerCaptureLost="ImageSurface_PointerCaptureLost"
KeyDown="ImageSurface_KeyDown"
AutomationProperties.Name="Source crop frame"
AutomationProperties.AutomationId="SourceCropHost">
<Image x:Name="SourceImage"
Stretch="Uniform"
Stretch="Fill"
IsHitTestVisible="False"
AutomationProperties.Name="Source video frame" />
<Rectangle x:Name="BrightnessOverlay"
IsHitTestVisible="False"
Fill="White"
Opacity="0" />
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
<Rectangle x:Name="SelectionRect"
Stroke="{ThemeResource AccentBrush}"
StrokeThickness="2"
Fill="#33007ACC"
Visibility="Collapsed" />
<Rectangle x:Name="HandleNW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleN" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleNE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleSE" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleS" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleSW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
<Rectangle x:Name="HandleW" Width="10" Height="10" Fill="{ThemeResource AccentBrush}" Stroke="{ThemeResource PageBrush}" StrokeThickness="1" Visibility="Collapsed" />
</Canvas>
</Grid>
</ScrollViewer>
</Border>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
+368 -56
View File
@@ -1,21 +1,43 @@
using System.Runtime.InteropServices.WindowsRuntime;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation;
using Windows.System;
using Windows.UI.Core;
namespace AmiReel.Controls;
public sealed partial class SourceCropEditor : UserControl
{
private const double HandleHitSize = 12;
private const double HandleVisualSize = 10;
private const double MinZoom = 1;
private const double MaxZoom = 4;
private const double ZoomStep = 0.25;
private const int ThumbnailWidth = 128;
private readonly List<Button> _frameButtons = [];
private readonly List<string> _framePaths = [];
private readonly List<string> _frameLabels = [];
private readonly List<BitmapImage?> _fullFrames = [];
private int _selectedFrame;
private int _pixelWidth;
private int _pixelHeight;
private double _zoom = 1;
private bool _isDragging;
private CropHit _dragHit = CropHit.None;
private Point _dragStart;
private Rect _dragOriginRect;
private PixelInsets _insets;
public SourceCropEditor()
@@ -26,26 +48,34 @@ public sealed partial class SourceCropEditor : UserControl
public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync(
string inputFile, string start, string ffmpegPath, string ffprobePath, CancellationToken token)
string inputFile, string start, double durationSeconds, string ffmpegPath, string ffprobePath, CancellationToken token)
{
LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading a source frame...";
StatusText.Text = "Loading preview frames...";
try
{
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor");
Directory.CreateDirectory(directory);
string framePath = Path.Combine(directory, "source-frame.png");
await new ShortsPipeline().ExtractSourceFrameAsync(
inputFile, start, ffmpegPath, ffprobePath, framePath, _ => { }, token);
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(durationSeconds);
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor", Guid.NewGuid().ToString("N"));
ShortsPipeline pipeline = new();
IReadOnlyList<string> paths = await pipeline.ExtractSourceFramesAsync(
inputFile, start, offsets, ffmpegPath, ffprobePath, directory, _ => { }, token);
await using FileStream stream = File.OpenRead(framePath);
BitmapImage bitmap = new();
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
SourceImage.Source = bitmap;
_pixelWidth = bitmap.PixelWidth;
_pixelHeight = bitmap.PixelHeight;
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · drag on the frame to choose the kept region.";
RefreshSelectionVisual();
_framePaths.Clear();
_frameLabels.Clear();
_fullFrames.Clear();
_frameButtons.Clear();
FrameStrip.Children.Clear();
for (int index = 0; index < paths.Count; index++)
{
_framePaths.Add(paths[index]);
_frameLabels.Add(ShortsPipeline.FormatPreviewOffsetLabel(offsets[index]));
_fullFrames.Add(null);
FrameStrip.Children.Add(CreateFrameButton(index, paths[index], _frameLabels[index]));
}
await ShowFrameAsync(0);
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · pick a still, then drag a region and fine-tune the handles.";
}
finally
{
@@ -60,112 +90,394 @@ public sealed partial class SourceCropEditor : UserControl
UpdateInsetsText();
}
private void CropHost_PointerPressed(object sender, PointerRoutedEventArgs e)
private Button CreateFrameButton(int index, string path, string label)
{
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out _, out _))
Image thumb = new()
{
Width = 112,
Height = 63,
Stretch = Stretch.Uniform,
IsHitTestVisible = false,
};
_ = LoadThumbnailAsync(thumb, path);
TextBlock caption = new()
{
Text = label,
FontSize = 11,
HorizontalAlignment = HorizontalAlignment.Center,
Style = (Style)Application.Current.Resources["MutedTextStyle"],
};
StackPanel content = new() { Spacing = 4 };
content.Children.Add(thumb);
content.Children.Add(caption);
Button button = new()
{
Content = content,
Padding = new Thickness(6),
Tag = index,
BorderThickness = new Thickness(2),
};
AutomationProperties.SetName(button, $"Preview at {label}");
AutomationProperties.SetAutomationId(button, $"CropPreviewFrame{index}");
button.Click += FrameButton_Click;
_frameButtons.Add(button);
return button;
}
private async void FrameButton_Click(object sender, RoutedEventArgs e)
{
if (sender is not Button button || button.Tag is not int index)
return;
try
{
await ShowFrameAsync(index);
}
catch (Exception exception)
{
StatusText.Text = exception.Message;
}
}
private async Task ShowFrameAsync(int index)
{
if (index < 0 || index >= _framePaths.Count)
return;
_selectedFrame = index;
UpdateFrameButtonStyles();
if (_fullFrames[index] is null)
_fullFrames[index] = await LoadBitmapAsync(_framePaths[index], decodePixelWidth: 0);
BitmapImage bitmap = _fullFrames[index]!;
SourceImage.Source = bitmap;
if (bitmap.PixelWidth > 0 && bitmap.PixelHeight > 0)
{
_pixelWidth = bitmap.PixelWidth;
_pixelHeight = bitmap.PixelHeight;
}
ApplyViewTransform();
RefreshSelectionVisual();
UpdateInsetsText();
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · {_frameLabels[index]} ({index + 1} of {_framePaths.Count})";
}
private void UpdateFrameButtonStyles()
{
Brush accent = (Brush)Application.Current.Resources["AccentBrush"];
Brush border = (Brush)Application.Current.Resources["BorderBrush"];
for (int index = 0; index < _frameButtons.Count; index++)
_frameButtons[index].BorderBrush = index == _selectedFrame ? accent : border;
}
private static async Task LoadThumbnailAsync(Image target, string path)
{
target.Source = await LoadBitmapAsync(path, ThumbnailWidth);
}
private static async Task<BitmapImage> LoadBitmapAsync(string path, int decodePixelWidth)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new();
if (decodePixelWidth > 0)
bitmap.DecodePixelWidth = decodePixelWidth;
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
private void ImageSurface_PointerPressed(object sender, PointerRoutedEventArgs e)
{
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
return;
ImageSurface.Focus(FocusState.Pointer);
(double rectX, double rectY, double rectW, double rectH) = CurrentDisplayRect(displayWidth, displayHeight);
CropHit hit = _insets.IsEmpty
? CropHit.None
: SourceCropSelection.HitTest(rectX, rectY, rectW, rectH, x, y, HandleHitSize);
_isDragging = true;
_dragHit = hit;
_dragStart = new Point(x, y);
CropHost.CapturePointer(e.Pointer);
UpdateDraftSelection(x, y);
_dragOriginRect = new Rect(rectX, rectY, rectW, rectH);
ImageSurface.CapturePointer(e.Pointer);
e.Handled = true;
}
private void CropHost_PointerMoved(object sender, PointerRoutedEventArgs e)
private void ImageSurface_PointerMoved(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
{
UpdateHoverCursor(e);
return;
if (!TryMapToDisplay(e, out double x, out double y, out _, out _))
}
if (!TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
return;
if (_dragHit == CropHit.None)
UpdateDraftSelection(x, y);
else
UpdateAdjustedSelection(x, y, displayWidth, displayHeight);
e.Handled = true;
}
private void CropHost_PointerReleased(object sender, PointerRoutedEventArgs e)
private void ImageSurface_PointerReleased(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
return;
_isDragging = false;
CropHost.ReleasePointerCapture(e.Pointer);
if (TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
CommitSelection(x, y, displayWidth, displayHeight);
EndDrag();
ImageSurface.ReleasePointerCapture(e.Pointer);
e.Handled = true;
}
private void CropHost_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
private void ImageSurface_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
_isDragging = false;
if (_isDragging)
EndDrag();
}
private void CropHost_SizeChanged(object sender, SizeChangedEventArgs e) => RefreshSelectionVisual();
private void EndDrag()
{
_isDragging = false;
_dragHit = CropHit.None;
RefreshSelectionVisual();
UpdateInsetsText();
}
private void ImageSurface_KeyDown(object sender, KeyRoutedEventArgs e)
{
if (_pixelWidth <= 0)
return;
bool shift = InputKeyboardSource.GetKeyStateForCurrentThread(VirtualKey.Shift).HasFlag(CoreVirtualKeyStates.Down);
int step = 2;
PixelInsets next = _insets;
switch (e.Key)
{
case VirtualKey.Left:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, -step, 0, 0, 0)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, -step, 0);
break;
case VirtualKey.Right:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, 0, 0, -step, 0)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, step, 0);
break;
case VirtualKey.Up:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, 0, -step, 0, 0)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, 0, -step);
break;
case VirtualKey.Down:
next = shift
? SourceCropSelection.NudgeEdge(_insets, _pixelWidth, _pixelHeight, 0, 0, 0, -step)
: SourceCropSelection.Nudge(_insets, _pixelWidth, _pixelHeight, 0, step);
break;
default:
return;
}
if (_insets.IsEmpty)
return;
SetInsets(next);
e.Handled = true;
}
private void CropScroller_SizeChanged(object sender, SizeChangedEventArgs e) => ApplyViewTransform();
private void CropScroller_PointerWheelChanged(object sender, PointerRoutedEventArgs e)
{
if (!e.KeyModifiers.HasFlag(VirtualKeyModifiers.Control))
return;
double delta = e.GetCurrentPoint(CropScroller).Properties.MouseWheelDelta > 0 ? ZoomStep : -ZoomStep;
SetZoom(_zoom + delta);
e.Handled = true;
}
private void ZoomSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
if (!IsLoaded)
return;
SetZoom(e.NewValue);
}
private void BrightnessSlider_ValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
if (!IsLoaded)
return;
ApplyBrightness(e.NewValue);
}
private void ClearCrop_Click(object sender, RoutedEventArgs e) => SetInsets(default);
private void SetZoom(double zoom)
{
_zoom = Math.Clamp(Math.Round(zoom / ZoomStep) * ZoomStep, MinZoom, MaxZoom);
if (Math.Abs(ZoomSlider.Value - _zoom) > 0.001)
ZoomSlider.Value = _zoom;
ZoomSlider.Header = $"Zoom {_zoom * 100:0}%";
ApplyViewTransform();
}
private void ApplyBrightness(double value)
{
double amount = Math.Clamp(value, -0.7, 0.7);
BrightnessOverlay.Fill = new SolidColorBrush(amount >= 0 ? Colors.White : Colors.Black);
BrightnessOverlay.Opacity = Math.Abs(amount);
BrightnessSlider.Header = amount == 0
? "View brightness"
: $"View brightness {(amount > 0 ? "+" : "")}{amount:0.00}";
}
private void ApplyViewTransform()
{
if (_pixelWidth <= 0)
return;
double viewportWidth = CropScroller.ActualWidth > 0 ? CropScroller.ActualWidth : 800;
double viewportHeight = CropScroller.ActualHeight > 0 ? CropScroller.ActualHeight : 420;
(_, _, double displayWidth, double displayHeight) =
SourceCropSelection.FitUniform(viewportWidth, viewportHeight, _pixelWidth, _pixelHeight);
ImageSurface.Width = Math.Max(1, displayWidth * _zoom);
ImageSurface.Height = Math.Max(1, displayHeight * _zoom);
RefreshSelectionVisual();
}
private bool TryMapToDisplay(
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight)
{
(double offsetX, double offsetY, displayWidth, displayHeight) = CurrentFit();
displayWidth = ImageSurface.Width;
displayHeight = ImageSurface.Height;
x = 0;
y = 0;
if (displayWidth <= 0 || displayHeight <= 0)
return false;
PointerPoint point = e.GetCurrentPoint(CropHost);
(x, y) = SourceCropSelection.ClampToDisplay(
point.Position.X - offsetX, point.Position.Y - offsetY, displayWidth, displayHeight);
PointerPoint point = e.GetCurrentPoint(ImageSurface);
(x, y) = SourceCropSelection.ClampToDisplay(point.Position.X, point.Position.Y, displayWidth, displayHeight);
return true;
}
private void UpdateDraftSelection(double x, double y)
{
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
double displayWidth = ImageSurface.Width;
double displayHeight = ImageSurface.Height;
if (displayWidth <= 0)
return;
double left = Math.Min(_dragStart.X, x);
double top = Math.Min(_dragStart.Y, y);
double width = Math.Abs(x - _dragStart.X);
double height = Math.Abs(y - _dragStart.Y);
ShowDisplayRect(left, top, width, height);
_insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
_pixelWidth, _pixelHeight, displayWidth, displayHeight, left, top, width, height);
UpdateInsetsText();
}
private void CommitSelection(double x, double y, double displayWidth, double displayHeight)
private void UpdateAdjustedSelection(double x, double y, double displayWidth, double displayHeight)
{
_insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
RefreshSelectionVisual();
(double nextX, double nextY, double nextW, double nextH) = SourceCropSelection.AdjustRect(
_dragOriginRect.X,
_dragOriginRect.Y,
_dragOriginRect.Width,
_dragOriginRect.Height,
_dragHit,
x - _dragStart.X,
y - _dragStart.Y,
displayWidth,
displayHeight);
ShowDisplayRect(nextX, nextY, nextW, nextH);
_insets = SourceCropSelection.ToInsets(_pixelWidth, _pixelHeight, displayWidth, displayHeight, nextX, nextY, nextW, nextH);
UpdateInsetsText();
}
private void RefreshSelectionVisual()
{
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
if (displayWidth <= 0 || _pixelWidth <= 0)
if (ImageSurface.Width <= 0 || _pixelWidth <= 0 || _insets.IsEmpty)
{
SelectionRect.Visibility = Visibility.Collapsed;
SetHandlesVisible(false);
return;
}
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
(double x, double y, double width, double height) = CurrentDisplayRect(ImageSurface.Width, ImageSurface.Height);
ShowDisplayRect(x, y, width, height);
}
private void PlaceSelectionRect(double offsetX, double offsetY, double displayWidth, double displayHeight)
private void ShowDisplayRect(double x, double y, double width, double height)
{
(double x, double y, double width, double height) = SourceCropSelection.ToDisplayRect(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets);
Canvas.SetLeft(SelectionRect, offsetX + x);
Canvas.SetTop(SelectionRect, offsetY + y);
SelectionRect.Width = width;
SelectionRect.Height = height;
SelectionRect.Visibility = _insets.IsEmpty ? Visibility.Collapsed : Visibility.Visible;
Canvas.SetLeft(SelectionRect, x);
Canvas.SetTop(SelectionRect, y);
SelectionRect.Width = Math.Max(1, width);
SelectionRect.Height = Math.Max(1, height);
SelectionRect.Visibility = Visibility.Visible;
PlaceHandle(HandleNW, x, y);
PlaceHandle(HandleN, x + (width / 2), y);
PlaceHandle(HandleNE, x + width, y);
PlaceHandle(HandleE, x + width, y + (height / 2));
PlaceHandle(HandleSE, x + width, y + height);
PlaceHandle(HandleS, x + (width / 2), y + height);
PlaceHandle(HandleSW, x, y + height);
PlaceHandle(HandleW, x, y + (height / 2));
SetHandlesVisible(true);
}
private (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) CurrentFit() =>
SourceCropSelection.FitUniform(CropHost.ActualWidth, CropHost.ActualHeight, _pixelWidth, _pixelHeight);
private (double X, double Y, double Width, double Height) CurrentDisplayRect(double displayWidth, double displayHeight) =>
SourceCropSelection.ToDisplayRect(_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets);
private void PlaceHandle(Microsoft.UI.Xaml.Shapes.Rectangle handle, double centerX, double centerY)
{
Canvas.SetLeft(handle, centerX - (HandleVisualSize / 2));
Canvas.SetTop(handle, centerY - (HandleVisualSize / 2));
}
private void SetHandlesVisible(bool visible)
{
Visibility visibility = visible ? Visibility.Visible : Visibility.Collapsed;
SelectionRect.Visibility = visibility;
HandleNW.Visibility = visibility;
HandleN.Visibility = visibility;
HandleNE.Visibility = visibility;
HandleE.Visibility = visibility;
HandleSE.Visibility = visibility;
HandleS.Visibility = visibility;
HandleSW.Visibility = visibility;
HandleW.Visibility = visibility;
}
private void UpdateHoverCursor(PointerRoutedEventArgs e)
{
if (_pixelWidth <= 0 || _insets.IsEmpty || !TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
{
ProtectedCursor = InputSystemCursor.Create(InputSystemCursorShape.Cross);
return;
}
(double rectX, double rectY, double rectW, double rectH) = CurrentDisplayRect(displayWidth, displayHeight);
CropHit hit = SourceCropSelection.HitTest(rectX, rectY, rectW, rectH, x, y, HandleHitSize);
ProtectedCursor = InputSystemCursor.Create(CursorFor(hit));
}
private static InputSystemCursorShape CursorFor(CropHit hit) => hit switch
{
CropHit.Move => InputSystemCursorShape.SizeAll,
CropHit.North or CropHit.South => InputSystemCursorShape.SizeNorthSouth,
CropHit.East or CropHit.West => InputSystemCursorShape.SizeWestEast,
CropHit.NorthWest or CropHit.SouthEast => InputSystemCursorShape.SizeNorthwestSoutheast,
CropHit.NorthEast or CropHit.SouthWest => InputSystemCursorShape.SizeNortheastSouthwest,
_ => InputSystemCursorShape.Cross,
};
private void UpdateInsetsText() =>
InsetsText.Text = _pixelWidth <= 0
+4
View File
@@ -612,9 +612,13 @@ public sealed partial class MainPage : Page
{
CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme;
double duration = 30;
try { duration = ParsePositiveDuration(); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync(
input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
duration,
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
+118 -8
View File
@@ -6,6 +6,21 @@ public readonly record struct PixelInsets(int Left, int Right, int Top, int Bott
public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0;
}
/// <summary>Which part of an existing crop rectangle a pointer is on.</summary>
public enum CropHit
{
None,
Move,
North,
South,
East,
West,
NorthWest,
NorthEast,
SouthWest,
SouthEast,
}
/// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary>
public static class SourceCropSelection
{
@@ -58,15 +73,14 @@ public static class SourceCropSelection
double scaleY = pixelHeight / displayHeight;
int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize);
int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize);
int right = ToEvenPixel((displayWidth - x - width) * scaleX, pixelWidth - MinimumSize - left);
int bottom = ToEvenPixel((displayHeight - y - height) * scaleY, pixelHeight - MinimumSize - top);
int cropWidth = ToEvenPixel(width * scaleX, pixelWidth - left);
int cropHeight = ToEvenPixel(height * scaleY, pixelHeight - top);
if (cropWidth < MinimumSize)
cropWidth = Math.Min(MinimumSize, pixelWidth - left);
if (cropHeight < MinimumSize)
cropHeight = Math.Min(MinimumSize, pixelHeight - top);
if (pixelWidth - left - right < MinimumSize)
right = Math.Max(0, pixelWidth - left - MinimumSize);
if (pixelHeight - top - bottom < MinimumSize)
bottom = Math.Max(0, pixelHeight - top - MinimumSize);
return new PixelInsets(left, right, top, bottom);
return new PixelInsets(left, pixelWidth - left - cropWidth, top, pixelHeight - top - cropHeight);
}
public static (double X, double Y, double Width, double Height) ToDisplayRect(
@@ -87,6 +101,102 @@ public static class SourceCropSelection
public static (double X, double Y) ClampToDisplay(double x, double y, double displayWidth, double displayHeight) =>
(Math.Clamp(x, 0, Math.Max(0, displayWidth)), Math.Clamp(y, 0, Math.Max(0, displayHeight)));
public static CropHit HitTest(
double rectX, double rectY, double rectWidth, double rectHeight, double pointerX, double pointerY, double handleSize)
{
if (rectWidth <= 0 || rectHeight <= 0 || handleSize < 0)
return CropHit.None;
bool nearLeft = Math.Abs(pointerX - rectX) <= handleSize;
bool nearRight = Math.Abs(pointerX - (rectX + rectWidth)) <= handleSize;
bool nearTop = Math.Abs(pointerY - rectY) <= handleSize;
bool nearBottom = Math.Abs(pointerY - (rectY + rectHeight)) <= handleSize;
bool alongX = pointerX >= rectX - handleSize && pointerX <= rectX + rectWidth + handleSize;
bool alongY = pointerY >= rectY - handleSize && pointerY <= rectY + rectHeight + handleSize;
if (nearTop && nearLeft)
return CropHit.NorthWest;
if (nearTop && nearRight)
return CropHit.NorthEast;
if (nearBottom && nearLeft)
return CropHit.SouthWest;
if (nearBottom && nearRight)
return CropHit.SouthEast;
if (nearTop && alongX)
return CropHit.North;
if (nearBottom && alongX)
return CropHit.South;
if (nearLeft && alongY)
return CropHit.West;
if (nearRight && alongY)
return CropHit.East;
if (pointerX >= rectX && pointerX <= rectX + rectWidth && pointerY >= rectY && pointerY <= rectY + rectHeight)
return CropHit.Move;
return CropHit.None;
}
public static (double X, double Y, double Width, double Height) AdjustRect(
double x,
double y,
double width,
double height,
CropHit hit,
double deltaX,
double deltaY,
double displayWidth,
double displayHeight)
{
const double minSize = 8;
double left = x;
double top = y;
double right = x + width;
double bottom = y + height;
if (hit == CropHit.Move)
{
left = Math.Clamp(left + deltaX, 0, Math.Max(0, displayWidth - width));
top = Math.Clamp(top + deltaY, 0, Math.Max(0, displayHeight - height));
return (left, top, width, height);
}
if (hit is CropHit.West or CropHit.NorthWest or CropHit.SouthWest)
left = Math.Clamp(left + deltaX, 0, right - minSize);
if (hit is CropHit.East or CropHit.NorthEast or CropHit.SouthEast)
right = Math.Clamp(right + deltaX, left + minSize, displayWidth);
if (hit is CropHit.North or CropHit.NorthWest or CropHit.NorthEast)
top = Math.Clamp(top + deltaY, 0, bottom - minSize);
if (hit is CropHit.South or CropHit.SouthWest or CropHit.SouthEast)
bottom = Math.Clamp(bottom + deltaY, top + minSize, displayHeight);
return (left, top, Math.Max(minSize, right - left), Math.Max(minSize, bottom - top));
}
public static PixelInsets Nudge(PixelInsets insets, int pixelWidth, int pixelHeight, int deltaX, int deltaY)
{
int width = pixelWidth - insets.Left - insets.Right;
int height = pixelHeight - insets.Top - insets.Bottom;
if (width < MinimumSize || height < MinimumSize)
return insets;
int left = ToEvenPixel(insets.Left + deltaX, pixelWidth - width);
int top = ToEvenPixel(insets.Top + deltaY, pixelHeight - height);
return new PixelInsets(left, pixelWidth - left - width, top, pixelHeight - top - height);
}
public static PixelInsets NudgeEdge(
PixelInsets insets, int pixelWidth, int pixelHeight, int dLeft, int dTop, int dRight, int dBottom)
{
int left = ToEvenPixel(insets.Left + dLeft, pixelWidth - MinimumSize);
int top = ToEvenPixel(insets.Top + dTop, pixelHeight - MinimumSize);
int right = ToEvenPixel(insets.Right + dRight, pixelWidth - MinimumSize - left);
int bottom = ToEvenPixel(insets.Bottom + dBottom, pixelHeight - MinimumSize - top);
if (pixelWidth - left - right < MinimumSize)
right = Math.Max(0, pixelWidth - left - MinimumSize);
if (pixelHeight - top - bottom < MinimumSize)
bottom = Math.Max(0, pixelHeight - top - MinimumSize);
return new PixelInsets(left, right, top, bottom);
}
private static void Normalize(ref double x, ref double y, ref double width, ref double height)
{
if (width < 0)
+2 -3
View File
@@ -120,11 +120,10 @@ process are intentionally left to manual/integration testing rather than unit te
.\publish-win-x64.ps1
```
This publishes a self-contained, single-file `win-x64` executable. At the end of the script
you'll see the release folder and final executable path, for example:
This publishes a self-contained, single-file `win-x64` executable and copies it to:
```text
bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\AmiReel.exe
dist\AmiReel.exe
```
## Installation
+56
View File
@@ -160,6 +160,62 @@ public sealed class ShortsPipeline
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
}
/// <summary>Writes several raw source frames across the clip so the crop editor can switch stills.</summary>
public async Task<IReadOnlyList<string>> ExtractSourceFramesAsync(
string inputFile,
string start,
IReadOnlyList<double> offsetsSeconds,
string ffmpegPath,
string ffprobePath,
string outputDirectory,
Action<string> log,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
if (offsetsSeconds.Count == 0)
throw new ArgumentException("At least one preview offset is required.");
Directory.CreateDirectory(outputDirectory);
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
List<string> paths = [];
for (int index = 0; index < offsetsSeconds.Count; index++)
{
string seek = AddTimestampOffset(start, offsetsSeconds[index]);
string path = Path.Combine(outputDirectory, $"frame-{index:00}.png");
log($"Extracting crop preview {index + 1} of {offsetsSeconds.Count} ({seek})...");
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, seek, path), log, null, token);
paths.Add(path);
}
return paths;
}
internal const int CropPreviewFrameCount = 6;
internal static IReadOnlyList<double> CropPreviewOffsets(double durationSeconds, int count = CropPreviewFrameCount)
{
if (count <= 1 || durationSeconds <= 0)
return [0];
double last = Math.Max(0, durationSeconds - 0.15);
if (last <= 0)
return [0];
double[] offsets = new double[count];
for (int index = 0; index < count; index++)
offsets[index] = Math.Round(last * index / (count - 1), 3, MidpointRounding.AwayFromZero);
return offsets;
}
internal static string FormatPreviewOffsetLabel(double seconds)
{
TimeSpan value = TimeSpan.FromSeconds(Math.Max(0, seconds));
return value.TotalHours >= 1
? value.ToString(@"h\:mm\:ss", CultureInfo.InvariantCulture)
: value.ToString(@"m\:ss", CultureInfo.InvariantCulture);
}
internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
[
"-hide_banner",
+17 -5
View File
@@ -16,6 +16,8 @@ $releaseDir = Join-Path $projectDir 'bin\Release\net10.0-windows10.0.26100.0\win
$releaseExe = Join-Path $releaseDir 'AmiReel.exe'
$publishDir = Join-Path $releaseDir 'publish'
$publishExe = Join-Path $publishDir 'AmiReel.exe'
$distDir = Join-Path $projectDir 'dist'
$distExe = Join-Path $distDir 'AmiReel.exe'
$publishArguments = @(
'publish', $projectPath,
'-c', 'Release',
@@ -27,11 +29,12 @@ $publishArguments = @(
'-p:EnableCompressionInSingleFile=true'
)
if ((Test-Path $releaseExe) -or (Test-Path $publishExe)) {
if ((Test-Path $releaseExe) -or (Test-Path $publishExe) -or (Test-Path $distExe)) {
$runningReleaseInstances = Get-Process | Where-Object {
$_.Path -and (
[string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($_.Path, $publishExe, [System.StringComparison]::OrdinalIgnoreCase)
[string]::Equals($_.Path, $publishExe, [System.StringComparison]::OrdinalIgnoreCase) -or
[string]::Equals($_.Path, $distExe, [System.StringComparison]::OrdinalIgnoreCase)
)
}
@@ -47,7 +50,16 @@ if (Test-Path $publishDir) {
}
& dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
Write-Host "Published AmiReel to $publishDir" -ForegroundColor Green
Write-Host "Release folder: $publishDir" -ForegroundColor Cyan
Write-Host "Executable: $publishExe" -ForegroundColor Cyan
if (-not (Test-Path $publishExe)) {
throw "Published executable was not found at $publishExe."
}
New-Item -ItemType Directory -Path $distDir -Force | Out-Null
Copy-Item -LiteralPath $publishExe -Destination $distExe -Force
Write-Host "Published AmiReel to $distDir" -ForegroundColor Green
Write-Host "Executable: $distExe" -ForegroundColor Cyan