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:
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user