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
+369 -57
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;
UpdateDraftSelection(x, y);
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