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/ [Dd]ebug/
[Rr]elease/ [Rr]elease/
publish/ publish/
dist/
# IDE / tooling # IDE / tooling
.vs/ .vs/
@@ -57,6 +57,19 @@ public class SourceCropSelectionTests
Assert.AreEqual(30, insets.Bottom); 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] [TestMethod]
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle() public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
{ {
@@ -97,4 +110,106 @@ public class SourceCropSelectionTests
Assert.AreEqual(0, x); Assert.AreEqual(0, x);
Assert.AreEqual(60, y); 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"); 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] [TestMethod]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng() public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{ {
+71 -8
View File
@@ -9,7 +9,7 @@
<StackPanel Spacing="10" MinWidth="640"> <StackPanel Spacing="10" MinWidth="640">
<TextBlock x:Name="StatusText" <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}" Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" /> TextWrapping="WrapWholeWords" />
<ProgressBar x:Name="LoadProgress" <ProgressBar x:Name="LoadProgress"
@@ -17,32 +17,95 @@
Height="6" Height="6"
Visibility="Collapsed" Visibility="Collapsed"
AutomationProperties.Name="Loading source frame" /> 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}" <Border Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}" BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1" BorderThickness="1"
CornerRadius="8"> CornerRadius="8">
<Grid x:Name="CropHost" <ScrollViewer x:Name="CropScroller"
Height="420" 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" Background="Transparent"
PointerPressed="CropHost_PointerPressed" IsTabStop="True"
PointerMoved="CropHost_PointerMoved" UseSystemFocusVisuals="True"
PointerReleased="CropHost_PointerReleased" PointerPressed="ImageSurface_PointerPressed"
PointerCaptureLost="CropHost_PointerCaptureLost" PointerMoved="ImageSurface_PointerMoved"
SizeChanged="CropHost_SizeChanged" PointerReleased="ImageSurface_PointerReleased"
PointerCaptureLost="ImageSurface_PointerCaptureLost"
KeyDown="ImageSurface_KeyDown"
AutomationProperties.Name="Source crop frame" AutomationProperties.Name="Source crop frame"
AutomationProperties.AutomationId="SourceCropHost"> AutomationProperties.AutomationId="SourceCropHost">
<Image x:Name="SourceImage" <Image x:Name="SourceImage"
Stretch="Uniform" Stretch="Fill"
IsHitTestVisible="False" IsHitTestVisible="False"
AutomationProperties.Name="Source video frame" /> AutomationProperties.Name="Source video frame" />
<Rectangle x:Name="BrightnessOverlay"
IsHitTestVisible="False"
Fill="White"
Opacity="0" />
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False"> <Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
<Rectangle x:Name="SelectionRect" <Rectangle x:Name="SelectionRect"
Stroke="{ThemeResource AccentBrush}" Stroke="{ThemeResource AccentBrush}"
StrokeThickness="2" StrokeThickness="2"
Fill="#33007ACC" Fill="#33007ACC"
Visibility="Collapsed" /> 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> </Canvas>
</Grid> </Grid>
</ScrollViewer>
</Border> </Border>
<Grid ColumnSpacing="10"> <Grid ColumnSpacing="10">
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
+368 -56
View File
@@ -1,21 +1,43 @@
using System.Runtime.InteropServices.WindowsRuntime; using System.Runtime.InteropServices.WindowsRuntime;
using AmiReel.Models; using AmiReel.Models;
using AmiReel.Services; using AmiReel.Services;
using Microsoft.UI;
using Microsoft.UI.Input; using Microsoft.UI.Input;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Automation;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Input; using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging; using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation; using Windows.Foundation;
using Windows.System;
using Windows.UI.Core;
namespace AmiReel.Controls; namespace AmiReel.Controls;
public sealed partial class SourceCropEditor : UserControl 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 _pixelWidth;
private int _pixelHeight; private int _pixelHeight;
private double _zoom = 1;
private bool _isDragging; private bool _isDragging;
private CropHit _dragHit = CropHit.None;
private Point _dragStart; private Point _dragStart;
private Rect _dragOriginRect;
private PixelInsets _insets; private PixelInsets _insets;
public SourceCropEditor() public SourceCropEditor()
@@ -26,26 +48,34 @@ public sealed partial class SourceCropEditor : UserControl
public PixelInsets Insets => _insets; public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync( 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; LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading a source frame..."; StatusText.Text = "Loading preview frames...";
try try
{ {
string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor"); IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(durationSeconds);
Directory.CreateDirectory(directory); string directory = Path.Combine(Path.GetTempPath(), "AmiReel", "crop-editor", Guid.NewGuid().ToString("N"));
string framePath = Path.Combine(directory, "source-frame.png"); ShortsPipeline pipeline = new();
await new ShortsPipeline().ExtractSourceFrameAsync( IReadOnlyList<string> paths = await pipeline.ExtractSourceFramesAsync(
inputFile, start, ffmpegPath, ffprobePath, framePath, _ => { }, token); inputFile, start, offsets, ffmpegPath, ffprobePath, directory, _ => { }, token);
await using FileStream stream = File.OpenRead(framePath); _framePaths.Clear();
BitmapImage bitmap = new(); _frameLabels.Clear();
await bitmap.SetSourceAsync(stream.AsRandomAccessStream()); _fullFrames.Clear();
SourceImage.Source = bitmap; _frameButtons.Clear();
_pixelWidth = bitmap.PixelWidth; FrameStrip.Children.Clear();
_pixelHeight = bitmap.PixelHeight;
StatusText.Text = $"{_pixelWidth} × {_pixelHeight} · drag on the frame to choose the kept region."; for (int index = 0; index < paths.Count; index++)
RefreshSelectionVisual(); {
_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 finally
{ {
@@ -60,112 +90,394 @@ public sealed partial class SourceCropEditor : UserControl
UpdateInsetsText(); 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; 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; _isDragging = true;
_dragHit = hit;
_dragStart = new Point(x, y); _dragStart = new Point(x, y);
CropHost.CapturePointer(e.Pointer); _dragOriginRect = new Rect(rectX, rectY, rectW, rectH);
UpdateDraftSelection(x, y); ImageSurface.CapturePointer(e.Pointer);
e.Handled = true; e.Handled = true;
} }
private void CropHost_PointerMoved(object sender, PointerRoutedEventArgs e) private void ImageSurface_PointerMoved(object sender, PointerRoutedEventArgs e)
{ {
if (!_isDragging) if (!_isDragging)
{
UpdateHoverCursor(e);
return; 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; return;
if (_dragHit == CropHit.None)
UpdateDraftSelection(x, y); UpdateDraftSelection(x, y);
else
UpdateAdjustedSelection(x, y, displayWidth, displayHeight);
e.Handled = true; e.Handled = true;
} }
private void CropHost_PointerReleased(object sender, PointerRoutedEventArgs e) private void ImageSurface_PointerReleased(object sender, PointerRoutedEventArgs e)
{ {
if (!_isDragging) if (!_isDragging)
return; return;
_isDragging = false; EndDrag();
CropHost.ReleasePointerCapture(e.Pointer); ImageSurface.ReleasePointerCapture(e.Pointer);
if (TryMapToDisplay(e, out double x, out double y, out double displayWidth, out double displayHeight))
CommitSelection(x, y, displayWidth, displayHeight);
e.Handled = true; 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 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( private bool TryMapToDisplay(
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight) 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; x = 0;
y = 0; y = 0;
if (displayWidth <= 0 || displayHeight <= 0) if (displayWidth <= 0 || displayHeight <= 0)
return false; return false;
PointerPoint point = e.GetCurrentPoint(CropHost); PointerPoint point = e.GetCurrentPoint(ImageSurface);
(x, y) = SourceCropSelection.ClampToDisplay( (x, y) = SourceCropSelection.ClampToDisplay(point.Position.X, point.Position.Y, displayWidth, displayHeight);
point.Position.X - offsetX, point.Position.Y - offsetY, displayWidth, displayHeight);
return true; return true;
} }
private void UpdateDraftSelection(double x, double y) 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) if (displayWidth <= 0)
return; 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( _insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _pixelWidth, _pixelHeight, displayWidth, displayHeight, left, top, width, height);
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
UpdateInsetsText(); 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( (double nextX, double nextY, double nextW, double nextH) = SourceCropSelection.AdjustRect(
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _dragOriginRect.X,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y); _dragOriginRect.Y,
RefreshSelectionVisual(); _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(); UpdateInsetsText();
} }
private void RefreshSelectionVisual() private void RefreshSelectionVisual()
{ {
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit(); if (ImageSurface.Width <= 0 || _pixelWidth <= 0 || _insets.IsEmpty)
if (displayWidth <= 0 || _pixelWidth <= 0)
{ {
SelectionRect.Visibility = Visibility.Collapsed; SetHandlesVisible(false);
return; 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( Canvas.SetLeft(SelectionRect, x);
_pixelWidth, _pixelHeight, displayWidth, displayHeight, _insets); Canvas.SetTop(SelectionRect, y);
Canvas.SetLeft(SelectionRect, offsetX + x); SelectionRect.Width = Math.Max(1, width);
Canvas.SetTop(SelectionRect, offsetY + y); SelectionRect.Height = Math.Max(1, height);
SelectionRect.Width = width; SelectionRect.Visibility = Visibility.Visible;
SelectionRect.Height = height; PlaceHandle(HandleNW, x, y);
SelectionRect.Visibility = _insets.IsEmpty ? Visibility.Collapsed : Visibility.Visible; 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() => private (double X, double Y, double Width, double Height) CurrentDisplayRect(double displayWidth, double displayHeight) =>
SourceCropSelection.FitUniform(CropHost.ActualWidth, CropHost.ActualHeight, _pixelWidth, _pixelHeight); 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() => private void UpdateInsetsText() =>
InsetsText.Text = _pixelWidth <= 0 InsetsText.Text = _pixelWidth <= 0
+4
View File
@@ -612,9 +612,13 @@ public sealed partial class MainPage : Page
{ {
CropEditorDialog.XamlRoot = XamlRoot; CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme; CropEditorDialog.RequestedTheme = ActualTheme;
double duration = 30;
try { duration = ParsePositiveDuration(); } catch (ArgumentException) { }
await ShortCropEditor.LoadVideoFrameAsync( await ShortCropEditor.LoadVideoFrameAsync(
input, input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(), string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
duration,
FfmpegPathBox.Text.Trim(), FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(), FfprobePathBox.Text.Trim(),
CancellationToken.None); 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; 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> /// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary>
public static class SourceCropSelection public static class SourceCropSelection
{ {
@@ -58,15 +73,14 @@ public static class SourceCropSelection
double scaleY = pixelHeight / displayHeight; double scaleY = pixelHeight / displayHeight;
int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize); int left = ToEvenPixel(x * scaleX, pixelWidth - MinimumSize);
int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize); int top = ToEvenPixel(y * scaleY, pixelHeight - MinimumSize);
int right = ToEvenPixel((displayWidth - x - width) * scaleX, pixelWidth - MinimumSize - left); int cropWidth = ToEvenPixel(width * scaleX, pixelWidth - left);
int bottom = ToEvenPixel((displayHeight - y - height) * scaleY, pixelHeight - MinimumSize - top); 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) return new PixelInsets(left, pixelWidth - left - cropWidth, top, pixelHeight - top - cropHeight);
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);
} }
public static (double X, double Y, double Width, double Height) ToDisplayRect( 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) => 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))); (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) private static void Normalize(ref double x, ref double y, ref double width, ref double height)
{ {
if (width < 0) 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 .\publish-win-x64.ps1
``` ```
This publishes a self-contained, single-file `win-x64` executable. At the end of the script This publishes a self-contained, single-file `win-x64` executable and copies it to:
you'll see the release folder and final executable path, for example:
```text ```text
bin\Release\net10.0-windows10.0.26100.0\win-x64\publish\AmiReel.exe dist\AmiReel.exe
``` ```
## Installation ## Installation
+56
View File
@@ -160,6 +160,62 @@ public sealed class ShortsPipeline
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token); 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) => internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
[ [
"-hide_banner", "-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' $releaseExe = Join-Path $releaseDir 'AmiReel.exe'
$publishDir = Join-Path $releaseDir 'publish' $publishDir = Join-Path $releaseDir 'publish'
$publishExe = Join-Path $publishDir 'AmiReel.exe' $publishExe = Join-Path $publishDir 'AmiReel.exe'
$distDir = Join-Path $projectDir 'dist'
$distExe = Join-Path $distDir 'AmiReel.exe'
$publishArguments = @( $publishArguments = @(
'publish', $projectPath, 'publish', $projectPath,
'-c', 'Release', '-c', 'Release',
@@ -27,11 +29,12 @@ $publishArguments = @(
'-p:EnableCompressionInSingleFile=true' '-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 { $runningReleaseInstances = Get-Process | Where-Object {
$_.Path -and ( $_.Path -and (
[string]::Equals($_.Path, $releaseExe, [System.StringComparison]::OrdinalIgnoreCase) -or [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 & dotnet @publishArguments
if ($LASTEXITCODE -ne 0) {
throw "dotnet publish failed with exit code $LASTEXITCODE."
}
Write-Host "Published AmiReel to $publishDir" -ForegroundColor Green if (-not (Test-Path $publishExe)) {
Write-Host "Release folder: $publishDir" -ForegroundColor Cyan throw "Published executable was not found at $publishExe."
Write-Host "Executable: $publishExe" -ForegroundColor Cyan }
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