Files
AmiReel/Controls/SourceCropEditor.xaml.cs
klevze e635b708a2 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.
2026-08-15 22:59:20 +02:00

487 lines
17 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
{
InitializeComponent();
}
public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync(
string inputFile, string start, double durationSeconds, string ffmpegPath, string ffprobePath, CancellationToken token)
{
LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading preview frames...";
try
{
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);
_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
{
LoadProgress.Visibility = Visibility.Collapsed;
}
}
public void SetInsets(PixelInsets insets)
{
_insets = insets;
RefreshSelectionVisual();
UpdateInsetsText();
}
private Button CreateFrameButton(int index, string path, string label)
{
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);
_dragOriginRect = new Rect(rectX, rectY, rectW, rectH);
ImageSurface.CapturePointer(e.Pointer);
e.Handled = true;
}
private void ImageSurface_PointerMoved(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
{
UpdateHoverCursor(e);
return;
}
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 ImageSurface_PointerReleased(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
return;
EndDrag();
ImageSurface.ReleasePointerCapture(e.Pointer);
e.Handled = true;
}
private void ImageSurface_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
if (_isDragging)
EndDrag();
}
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)
{
displayWidth = ImageSurface.Width;
displayHeight = ImageSurface.Height;
x = 0;
y = 0;
if (displayWidth <= 0 || displayHeight <= 0)
return false;
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 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, left, top, width, height);
UpdateInsetsText();
}
private void UpdateAdjustedSelection(double x, double y, double displayWidth, double displayHeight)
{
(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()
{
if (ImageSurface.Width <= 0 || _pixelWidth <= 0 || _insets.IsEmpty)
{
SetHandlesVisible(false);
return;
}
(double x, double y, double width, double height) = CurrentDisplayRect(ImageSurface.Width, ImageSurface.Height);
ShowDisplayRect(x, y, width, height);
}
private void ShowDisplayRect(double x, double y, double width, double height)
{
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 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
? "Crop L0 R0 T0 B0"
: $"Crop L{_insets.Left} R{_insets.Right} T{_insets.Top} B{_insets.Bottom} · {_pixelWidth - _insets.Left - _insets.Right}×{_pixelHeight - _insets.Top - _insets.Bottom}";
}