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.
487 lines
17 KiB
C#
487 lines
17 KiB
C#
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}";
|
||
}
|