Add Shorts look preview and visual crop editor

Show opening/closing 9:16 stills from the same filtergraph, and let
users drag a keep-region on a source frame to fill the crop insets.
This commit is contained in:
2026-08-15 21:08:36 +02:00
parent 8a734d58d9
commit 323324d713
9 changed files with 958 additions and 12 deletions
@@ -0,0 +1,100 @@
using AmiReel.Models;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Models;
[TestClass]
public class SourceCropSelectionTests
{
[TestMethod]
public void FitUniform_WiderContainer_LetterboxesHorizontally()
{
// Act
(double offsetX, double offsetY, double width, double height) =
SourceCropSelection.FitUniform(400, 200, 100, 100);
// Assert
Assert.AreEqual(100, offsetX);
Assert.AreEqual(0, offsetY);
Assert.AreEqual(200, width);
Assert.AreEqual(200, height);
}
[TestMethod]
public void FitUniform_TallerContainer_LetterboxesVertically()
{
// Act
(double offsetX, double offsetY, double width, double height) =
SourceCropSelection.FitUniform(200, 400, 100, 100);
// Assert
Assert.AreEqual(0, offsetX);
Assert.AreEqual(100, offsetY);
Assert.AreEqual(200, width);
Assert.AreEqual(200, height);
}
[TestMethod]
public void ToInsets_FullFrameSelection_ReturnsEmptyInsets()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(640, 512, 640, 512, 0, 0, 640, 512);
// Assert
Assert.IsTrue(insets.IsEmpty);
}
[TestMethod]
public void ToInsets_CenteredSelection_ComputesAllFourEdges()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 10, 20, 70, 50);
// Assert
Assert.AreEqual(10, insets.Left);
Assert.AreEqual(20, insets.Right);
Assert.AreEqual(20, insets.Top);
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToInsets_DraggedUpAndLeft_NormalizesTheRectangle()
{
// Act
PixelInsets insets = SourceCropSelection.ToInsets(100, 100, 100, 100, 80, 70, -70, -50);
// Assert
Assert.AreEqual(10, insets.Left);
Assert.AreEqual(20, insets.Right);
Assert.AreEqual(20, insets.Top);
Assert.AreEqual(30, insets.Bottom);
}
[TestMethod]
public void ToDisplayRect_RoundTripsKnownInsets()
{
// Arrange
PixelInsets insets = new(20, 20, 10, 10);
// Act
(double x, double y, double width, double height) =
SourceCropSelection.ToDisplayRect(100, 100, 200, 200, insets);
// Assert
Assert.AreEqual(40, x);
Assert.AreEqual(20, y);
Assert.AreEqual(120, width);
Assert.AreEqual(160, height);
}
[TestMethod]
public void ClampToDisplay_OutsideBounds_ClampsToEdges()
{
// Act
(double x, double y) = SourceCropSelection.ClampToDisplay(-8, 90, 80, 60);
// Assert
Assert.AreEqual(0, x);
Assert.AreEqual(60, y);
}
}
@@ -389,4 +389,128 @@ public class ShortsPipelineTests
// Assert
Assert.AreEqual(1000, width);
}
[TestMethod]
public void ValidatePreview_WithMissingTitleAndOutput_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf")) with
{
Title = "",
OutputPath = "",
};
// Act & Assert (no exception)
ShortsPipeline.ValidatePreview(settings);
}
[TestMethod]
public void ValidatePreview_WithMissingInput_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.ValidatePreview(settings));
}
[TestMethod]
public void PreviewOverlayWindows_Opening_HidesTitleCard()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Opening, 30);
// Assert
Assert.AreEqual(30, hookEnd);
Assert.AreEqual(31, infoStart);
Assert.AreEqual(31, ctaStart);
}
[TestMethod]
public void PreviewOverlayWindows_Closing_ShowsTitlesImmediately()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(-1, hookEnd);
Assert.AreEqual(0, infoStart);
Assert.AreEqual(0, ctaStart);
}
[TestMethod]
public void PreviewSeekSeconds_OpeningAndClosing_StayInsideDuration()
{
// Act
double opening = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Opening, 30);
double closing = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(1.2, opening);
Assert.AreEqual(29.6, closing);
}
[TestMethod]
public void AddTimestampOffset_AddsFractionalSeconds()
{
// Act
string result = ShortsPipeline.AddTimestampOffset("00:01:10", 1.2);
// Assert
Assert.AreEqual("00:01:11.200", result);
}
[TestMethod]
public void BuildInputArguments_VideoStyle_IncludesLoopedBackgroundVideo()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf", ShortStyle.Video) with
{
BackgroundVideo = @"D:\bg.mp4",
};
// Act
List<string> args = ShortsPipeline.BuildInputArguments(settings, 0.12, "00:00:01.200");
// Assert
CollectionAssert.Contains(args, "00:00:01.200");
CollectionAssert.Contains(args, "-stream_loop");
CollectionAssert.Contains(args, @"D:\bg.mp4");
CollectionAssert.Contains(args, "in.mp4");
}
[TestMethod]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{
// Act
List<string> args = ShortsPipeline.BuildExtractFrameArguments(@"D:\clip.mp4", "00:00:05", @"C:\tmp\frame.png");
// Assert
CollectionAssert.Contains(args, "00:00:05");
CollectionAssert.Contains(args, @"D:\clip.mp4");
CollectionAssert.Contains(args, "-frames:v");
CollectionAssert.Contains(args, "1");
CollectionAssert.Contains(args, @"C:\tmp\frame.png");
}
[TestMethod]
public void BuildFilterComplex_WithoutTextOverlays_SkipsDrawtext()
{
// Act
string result = ShortsPipeline.BuildFilterComplex(
"[0:v]copy[base]",
fontEsc: "", hookEsc: "", titleEsc: "", metaEsc: "", websiteEsc: "",
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5,
includeTextOverlays: false);
// Assert
StringAssert.Contains(result, "[0:v]copy[base]");
StringAssert.Contains(result, "[vout]");
Assert.IsFalse(result.Contains("drawtext", StringComparison.Ordinal));
Assert.IsFalse(result.Contains("drawbox", StringComparison.Ordinal));
}
}
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="AmiReel.Controls.SourceCropEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<StackPanel Spacing="10" MinWidth="640">
<TextBlock x:Name="StatusText"
Text="Drag on the frame to keep that region. The rest is cropped away."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<ProgressBar x:Name="LoadProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Loading source frame" />
<Border Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Grid x:Name="CropHost"
Height="420"
Background="Transparent"
PointerPressed="CropHost_PointerPressed"
PointerMoved="CropHost_PointerMoved"
PointerReleased="CropHost_PointerReleased"
PointerCaptureLost="CropHost_PointerCaptureLost"
SizeChanged="CropHost_SizeChanged"
AutomationProperties.Name="Source crop frame"
AutomationProperties.AutomationId="SourceCropHost">
<Image x:Name="SourceImage"
Stretch="Uniform"
IsHitTestVisible="False"
AutomationProperties.Name="Source video frame" />
<Canvas x:Name="OverlayCanvas" IsHitTestVisible="False">
<Rectangle x:Name="SelectionRect"
Stroke="{ThemeResource AccentBrush}"
StrokeThickness="2"
Fill="#33007ACC"
Visibility="Collapsed" />
</Canvas>
</Grid>
</Border>
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock x:Name="InsetsText"
Grid.Column="0"
Text="Crop L0 R0 T0 B0"
Style="{StaticResource MutedTextStyle}"
VerticalAlignment="Center" />
<Button x:Name="ClearCropButton"
Grid.Column="1"
Content="Clear crop"
Click="ClearCrop_Click"
AutomationProperties.Name="Clear crop"
AutomationProperties.AutomationId="ClearSourceCropButton" />
</Grid>
</StackPanel>
</UserControl>
+174
View File
@@ -0,0 +1,174 @@
using System.Runtime.InteropServices.WindowsRuntime;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation;
namespace AmiReel.Controls;
public sealed partial class SourceCropEditor : UserControl
{
private int _pixelWidth;
private int _pixelHeight;
private bool _isDragging;
private Point _dragStart;
private PixelInsets _insets;
public SourceCropEditor()
{
InitializeComponent();
}
public PixelInsets Insets => _insets;
public async Task LoadVideoFrameAsync(
string inputFile, string start, string ffmpegPath, string ffprobePath, CancellationToken token)
{
LoadProgress.Visibility = Visibility.Visible;
StatusText.Text = "Loading a source frame...";
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);
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();
}
finally
{
LoadProgress.Visibility = Visibility.Collapsed;
}
}
public void SetInsets(PixelInsets insets)
{
_insets = insets;
RefreshSelectionVisual();
UpdateInsetsText();
}
private void CropHost_PointerPressed(object sender, PointerRoutedEventArgs e)
{
if (_pixelWidth <= 0 || !TryMapToDisplay(e, out double x, out double y, out _, out _))
return;
_isDragging = true;
_dragStart = new Point(x, y);
CropHost.CapturePointer(e.Pointer);
UpdateDraftSelection(x, y);
e.Handled = true;
}
private void CropHost_PointerMoved(object sender, PointerRoutedEventArgs e)
{
if (!_isDragging)
return;
if (!TryMapToDisplay(e, out double x, out double y, out _, out _))
return;
UpdateDraftSelection(x, y);
e.Handled = true;
}
private void CropHost_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);
e.Handled = true;
}
private void CropHost_PointerCaptureLost(object sender, PointerRoutedEventArgs e)
{
_isDragging = false;
}
private void CropHost_SizeChanged(object sender, SizeChangedEventArgs e) => RefreshSelectionVisual();
private void ClearCrop_Click(object sender, RoutedEventArgs e) => SetInsets(default);
private bool TryMapToDisplay(
PointerRoutedEventArgs e, out double x, out double y, out double displayWidth, out double displayHeight)
{
(double offsetX, double offsetY, displayWidth, displayHeight) = CurrentFit();
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);
return true;
}
private void UpdateDraftSelection(double x, double y)
{
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
if (displayWidth <= 0)
return;
_insets = SourceCropSelection.ToInsets(
_pixelWidth, _pixelHeight, displayWidth, displayHeight,
_dragStart.X, _dragStart.Y, x - _dragStart.X, y - _dragStart.Y);
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
UpdateInsetsText();
}
private void CommitSelection(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();
UpdateInsetsText();
}
private void RefreshSelectionVisual()
{
(double offsetX, double offsetY, double displayWidth, double displayHeight) = CurrentFit();
if (displayWidth <= 0 || _pixelWidth <= 0)
{
SelectionRect.Visibility = Visibility.Collapsed;
return;
}
PlaceSelectionRect(offsetX, offsetY, displayWidth, displayHeight);
}
private void PlaceSelectionRect(double offsetX, double offsetY, double displayWidth, double displayHeight)
{
(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;
}
private (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) CurrentFit() =>
SourceCropSelection.FitUniform(CropHost.ActualWidth, CropHost.ActualHeight, _pixelWidth, _pixelHeight);
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}";
}
+82 -2
View File
@@ -5,6 +5,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="using:AmiReel.Controls"
mc:Ignorable="d">
<Grid x:Name="RootGrid" Padding="20" RowSpacing="16" Background="{ThemeResource PageBrush}">
@@ -87,7 +88,12 @@
</Grid>
<Border Grid.Row="1" Padding="18" Style="{StaticResource CardBorderStyle}" x:Name="ShortsContentPanel" Visibility="Collapsed">
<StackPanel Spacing="14">
<Grid ColumnSpacing="18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Spacing="14">
<StackPanel Spacing="4">
<TextBlock x:Name="ShortWizardTitleText" Text="Source clip" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="ShortWizardStepText" Text="Step 1 of 4" Style="{StaticResource MutedTextStyle}" />
@@ -207,7 +213,7 @@
<ComboBoxItem Content="Auto (remove left/right black borders)" Tag="Auto" />
<ComboBoxItem Content="Center 4:3" Tag="Center" />
</ComboBox>
<TextBlock Text="Manual crop overrides auto/center. Leave at 0 to use the mode above."
<TextBlock Text="Manual crop overrides auto/center. Leave at 0 to use the mode above, or drag the kept region on a source frame."
Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
<Grid ColumnSpacing="10">
<Grid.ColumnDefinitions>
@@ -225,6 +231,12 @@
<TextBox x:Name="ShortCropBottomBox" Grid.Column="3" Header="Crop bottom" Text="0"
AutomationProperties.Name="Crop bottom pixels" />
</Grid>
<Button x:Name="OpenCropEditorButton"
Content="Crop visually..."
Click="OpenCropEditor_Click"
HorizontalAlignment="Left"
AutomationProperties.Name="Open visual crop editor"
AutomationProperties.AutomationId="OpenCropEditorButton" />
</StackPanel>
</StackPanel>
@@ -284,6 +296,56 @@
<TextBlock x:Name="ShortSummaryText" Text="" Style="{StaticResource MutedTextStyle}" TextWrapping="WrapWholeWords" />
</StackPanel>
</StackPanel>
<StackPanel Grid.Column="1" Width="308" Spacing="10">
<TextBlock Text="Preview" Style="{StaticResource SectionTitleStyle}" />
<TextBlock x:Name="ShortPreviewStatusText"
Text="Pick a source clip, then update the preview to see the 9:16 look."
Style="{StaticResource MutedTextStyle}"
TextWrapping="WrapWholeWords" />
<Button x:Name="UpdateShortPreviewButton"
Content="Update preview"
Click="UpdateShortPreview_Click"
HorizontalAlignment="Stretch"
AutomationProperties.Name="Update short preview"
AutomationProperties.AutomationId="UpdateShortPreviewButton" />
<ProgressBar x:Name="ShortPreviewProgress"
IsIndeterminate="True"
Height="6"
Visibility="Collapsed"
AutomationProperties.Name="Preview progress" />
<Grid ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Spacing="6">
<TextBlock Text="Opening" Style="{StaticResource MutedTextStyle}" />
<Border Width="148" Height="263"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="ShortPreviewOpeningImage"
Stretch="Uniform"
AutomationProperties.Name="Opening hook preview" />
</Border>
</StackPanel>
<StackPanel Grid.Column="1" Spacing="6">
<TextBlock Text="Closing" Style="{StaticResource MutedTextStyle}" />
<Border Width="148" Height="263"
Background="{ThemeResource PageBrush}"
BorderBrush="{ThemeResource BorderBrush}"
BorderThickness="1"
CornerRadius="8">
<Image x:Name="ShortPreviewClosingImage"
Stretch="Uniform"
AutomationProperties.Name="Closing title-card preview" />
</Border>
</StackPanel>
</Grid>
</StackPanel>
</Grid>
</Border>
<Border x:Name="RenderProgressPanel"
@@ -352,6 +414,24 @@
</StackPanel>
</Grid>
<ContentDialog x:Name="CropEditorDialog"
Title="Crop source"
PrimaryButtonText="Use this crop"
CloseButtonText="Cancel"
DefaultButton="Primary"
Background="{ThemeResource PanelBrush}"
Foreground="{ThemeResource TextBrush}"
BorderBrush="{ThemeResource HeroBorderBrush}"
BorderThickness="1.5"
CornerRadius="18"
PrimaryButtonStyle="{StaticResource DialogPrimaryButtonStyle}"
CloseButtonStyle="{StaticResource DialogCloseButtonStyle}">
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">920</x:Double>
</ContentDialog.Resources>
<controls:SourceCropEditor x:Name="ShortCropEditor" />
</ContentDialog>
<ContentDialog x:Name="SettingsDialog"
Title="Settings"
PrimaryButtonText="Done"
+120
View File
@@ -1,11 +1,13 @@
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.Runtime.InteropServices.WindowsRuntime;
using System.Text;
using AmiReel.Models;
using AmiReel.Services;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage;
using Windows.Storage.Pickers;
@@ -27,6 +29,8 @@ public sealed partial class MainPage : Page
private bool _keepRenderOutput;
private List<ShortWizardStep> _wizardSteps = [.. ShortWizard.StepsFor(ShortStyle.Brand)];
private int _wizardIndex;
private int _previewGeneration;
private string? _previewDirectory;
public MainPage()
{
@@ -150,6 +154,8 @@ public sealed partial class MainPage : Page
ShortSummaryText.Text = BuildShortSummary();
UpdateWizardNavigation();
if (IsLoaded && step != ShortWizardStep.Clip)
_ = RefreshShortPreviewAsync(automatic: true);
}
private void UpdateWizardNavigation()
@@ -593,12 +599,126 @@ public sealed partial class MainPage : Page
ShortFontBox.Text = file.Path;
}
private async void OpenCropEditor_Click(object sender, RoutedEventArgs e)
{
string input = ShortInputBox.Text.Trim();
if (string.IsNullOrWhiteSpace(input) || !File.Exists(input))
{
await ShowMessageAsync("Crop source", "Select a valid source video first.");
return;
}
try
{
CropEditorDialog.XamlRoot = XamlRoot;
CropEditorDialog.RequestedTheme = ActualTheme;
await ShortCropEditor.LoadVideoFrameAsync(
input,
string.IsNullOrWhiteSpace(ShortStartBox.Text) ? "00:00:00" : ShortStartBox.Text.Trim(),
FfmpegPathBox.Text.Trim(),
FfprobePathBox.Text.Trim(),
CancellationToken.None);
ShortCropEditor.SetInsets(ReadCropInsets());
ContentDialogResult result = await CropEditorDialog.ShowAsync();
if (result != ContentDialogResult.Primary)
return;
PixelInsets insets = ShortCropEditor.Insets;
ShortCropLeftBox.Text = insets.Left.ToString(CultureInfo.InvariantCulture);
ShortCropRightBox.Text = insets.Right.ToString(CultureInfo.InvariantCulture);
ShortCropTopBox.Text = insets.Top.ToString(CultureInfo.InvariantCulture);
ShortCropBottomBox.Text = insets.Bottom.ToString(CultureInfo.InvariantCulture);
}
catch (Exception exception)
{
await ShowMessageAsync("Crop source", UserFacingErrors.Summarize(exception));
}
}
private PixelInsets ReadCropInsets()
{
static int ReadBox(string text)
{
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int value) && value > 0
? value
: 0;
}
return new PixelInsets(
ReadBox(ShortCropLeftBox.Text),
ReadBox(ShortCropRightBox.Text),
ReadBox(ShortCropTopBox.Text),
ReadBox(ShortCropBottomBox.Text));
}
private async void PreviewShortSource_Click(object sender, RoutedEventArgs e)
{
if (!string.IsNullOrWhiteSpace(ShortInputBox.Text))
await OpenPreviewAsync(ShortInputBox.Text.Trim());
}
private async void UpdateShortPreview_Click(object sender, RoutedEventArgs e) =>
await RefreshShortPreviewAsync(automatic: false);
private async Task RefreshShortPreviewAsync(bool automatic)
{
if (_isRendering)
return;
int generation = ++_previewGeneration;
ShortPreviewProgress.Visibility = Visibility.Visible;
UpdateShortPreviewButton.IsEnabled = false;
ShortPreviewStatusText.Text = "Rendering 9:16 preview frames...";
try
{
ShortSettings settings = ReadShortSettings();
ShortsPipeline.ValidatePreview(settings);
_previewDirectory ??= Path.Combine(Path.GetTempPath(), "AmiReel", "short-preview", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_previewDirectory);
ShortPreviewFrames frames = await new ShortsPipeline().RenderPreviewAsync(
settings, _previewDirectory, _ => { }, CancellationToken.None);
if (generation != _previewGeneration)
return;
ShortPreviewOpeningImage.Source = await LoadPreviewBitmapAsync(frames.OpeningPath);
ShortPreviewClosingImage.Source = await LoadPreviewBitmapAsync(frames.ClosingPath);
bool hasFont = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
ShortPreviewStatusText.Text = hasFont
? $"{settings.Style} · opening hook and closing title card"
: "Look only — pick a font on the last step to include the title-card text.";
}
catch (Exception exception)
{
if (generation != _previewGeneration)
return;
string message = UserFacingErrors.Summarize(exception);
ShortPreviewStatusText.Text = message;
if (!automatic)
await ShowMessageAsync("Preview failed", message);
}
finally
{
if (generation == _previewGeneration)
{
ShortPreviewProgress.Visibility = Visibility.Collapsed;
UpdateShortPreviewButton.IsEnabled = true;
}
}
}
private static async Task<BitmapImage> LoadPreviewBitmapAsync(string path)
{
await using FileStream stream = File.OpenRead(path);
BitmapImage bitmap = new() { DecodePixelWidth = 296 };
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
return bitmap;
}
private void SetRendering(bool rendering)
{
_isRendering = rendering;
+10
View File
@@ -32,6 +32,16 @@ public enum ShortSourceCropMode
Center,
}
/// <summary>Which moment of the Short a preview still represents.</summary>
public enum ShortPreviewShot
{
Opening,
Closing,
}
/// <summary>PNG stills of the opening hook and closing title card.</summary>
public sealed record ShortPreviewFrames(string OpeningPath, string ClosingPath);
/// <summary>One YouTube Shorts render job's parameters (port of amigadb-short-fx-v4.sh).</summary>
public sealed record ShortSettings
{
+111
View File
@@ -0,0 +1,111 @@
namespace AmiReel.Models;
/// <summary>Edge insets, in source pixels, removed by a manual crop.</summary>
public readonly record struct PixelInsets(int Left, int Right, int Top, int Bottom)
{
public bool IsEmpty => Left == 0 && Right == 0 && Top == 0 && Bottom == 0;
}
/// <summary>Maps a dragged display rectangle onto source-pixel crop insets.</summary>
public static class SourceCropSelection
{
public const int MinimumSize = 16;
public static (double OffsetX, double OffsetY, double DisplayWidth, double DisplayHeight) FitUniform(
double containerWidth, double containerHeight, int pixelWidth, int pixelHeight)
{
if (containerWidth <= 0 || containerHeight <= 0 || pixelWidth <= 0 || pixelHeight <= 0)
return (0, 0, 0, 0);
double containerAspect = containerWidth / containerHeight;
double imageAspect = pixelWidth / (double)pixelHeight;
double displayWidth;
double displayHeight;
if (containerAspect > imageAspect)
{
displayHeight = containerHeight;
displayWidth = containerHeight * imageAspect;
}
else
{
displayWidth = containerWidth;
displayHeight = containerWidth / imageAspect;
}
return ((containerWidth - displayWidth) / 2, (containerHeight - displayHeight) / 2, displayWidth, displayHeight);
}
public static PixelInsets ToInsets(
int pixelWidth,
int pixelHeight,
double displayWidth,
double displayHeight,
double x,
double y,
double width,
double height)
{
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
return default;
Normalize(ref x, ref y, ref width, ref height);
x = Math.Clamp(x, 0, displayWidth);
y = Math.Clamp(y, 0, displayHeight);
width = Math.Clamp(width, 0, displayWidth - x);
height = Math.Clamp(height, 0, displayHeight - y);
double scaleX = pixelWidth / displayWidth;
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);
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);
}
public static (double X, double Y, double Width, double Height) ToDisplayRect(
int pixelWidth, int pixelHeight, double displayWidth, double displayHeight, PixelInsets insets)
{
if (pixelWidth <= 0 || pixelHeight <= 0 || displayWidth <= 0 || displayHeight <= 0)
return (0, 0, Math.Max(0, displayWidth), Math.Max(0, displayHeight));
double scaleX = displayWidth / pixelWidth;
double scaleY = displayHeight / pixelHeight;
double x = insets.Left * scaleX;
double y = insets.Top * scaleY;
double width = Math.Max(1, (pixelWidth - insets.Left - insets.Right) * scaleX);
double height = Math.Max(1, (pixelHeight - insets.Top - insets.Bottom) * scaleY);
return (x, y, width, height);
}
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)));
private static void Normalize(ref double x, ref double y, ref double width, ref double height)
{
if (width < 0)
{
x += width;
width = -width;
}
if (height < 0)
{
y += height;
height = -height;
}
}
private static int ToEvenPixel(double value, int max)
{
int rounded = (int)Math.Round(value, MidpointRounding.AwayFromZero);
int clamped = Math.Clamp(rounded, 0, Math.Max(0, max));
return (clamped / 2) * 2;
}
}
+172 -10
View File
@@ -65,11 +65,7 @@ public sealed class ShortsPipeline
websiteEsc: EscapeFfmpegPath(websiteFile),
hookEnd, infoStart, ctaStart);
List<string> args = ["-hide_banner", "-y", "-ss", settings.Start, "-t", F(duration), "-i", settings.InputFile];
if (settings.Style == ShortStyle.Brand)
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
if (settings.Style == ShortStyle.Video)
args.AddRange(["-stream_loop", "-1", "-i", settings.BackgroundVideo]);
List<string> args = BuildInputArguments(settings, duration);
args.AddRange([
"-filter_complex", filterComplex,
@@ -107,6 +103,78 @@ public sealed class ShortsPipeline
}
}
/// <summary>
/// Renders two 9:16 stills with the same filtergraph as the final Short: opening hook and closing title card.
/// </summary>
public async Task<ShortPreviewFrames> RenderPreviewAsync(
ShortSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
{
ValidatePreview(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
Directory.CreateDirectory(outputDirectory);
string workDir = Path.Combine(outputDirectory, "meta");
Directory.CreateDirectory(workDir);
string hookFile = WriteTextFile(workDir, "hook.txt", string.IsNullOrWhiteSpace(settings.Hook) ? "THIS RAN ON AN AMIGA" : settings.Hook);
string titleFile = WriteTextFile(workDir, "title.txt", string.IsNullOrWhiteSpace(settings.Title) ? "TITLE" : settings.Title);
string metaFile = WriteTextFile(workDir, "meta.txt", BuildMeta(settings));
string websiteFile = WriteTextFile(workDir, "website.txt", string.IsNullOrWhiteSpace(settings.Website) ? "AMIGADB.NET" : settings.Website);
log("Resolving preview look...");
string baseFilter = await ResolveBaseFilterAsync(tools, settings, log, token);
bool includeText = !string.IsNullOrWhiteSpace(settings.FontFile) && File.Exists(settings.FontFile);
string openingPath = Path.Combine(outputDirectory, "opening.png");
string closingPath = Path.Combine(outputDirectory, "closing.png");
await RenderPreviewStillAsync(
tools, settings, baseFilter, includeText,
hookFile, titleFile, metaFile, websiteFile,
ShortPreviewShot.Opening, openingPath, log, token);
await RenderPreviewStillAsync(
tools, settings, baseFilter, includeText,
hookFile, titleFile, metaFile, websiteFile,
ShortPreviewShot.Closing, closingPath, log, token);
return new ShortPreviewFrames(openingPath, closingPath);
}
/// <summary>Writes one source frame (no style filters) so the crop editor can show the raw clip.</summary>
public async Task ExtractSourceFrameAsync(
string inputFile,
string start,
string ffmpegPath,
string ffprobePath,
string outputPath,
Action<string> log,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(inputFile) || !File.Exists(inputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
string? directory = Path.GetDirectoryName(Path.GetFullPath(outputPath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
ToolPaths tools = await ToolExtractor.ResolveAsync(ffmpegPath, ffprobePath, token);
await _runner.RunAsync(tools.Ffmpeg, BuildExtractFrameArguments(inputFile, start, outputPath), log, null, token);
}
internal static List<string> BuildExtractFrameArguments(string inputFile, string start, string outputPath) =>
[
"-hide_banner",
"-y",
"-ss",
string.IsNullOrWhiteSpace(start) ? "00:00:00" : start,
"-i",
inputFile,
"-frames:v",
"1",
"-update",
"1",
outputPath,
];
/// <summary>Looks for a common bold system font to pre-fill the Shorts font field with.</summary>
public static string? FindDefaultFont()
{
@@ -122,16 +190,21 @@ public sealed class ShortsPipeline
internal static void Validate(ShortSettings s)
{
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
ValidatePreview(s);
if (string.IsNullOrWhiteSpace(s.Title))
throw new ArgumentException("Enter a title.");
if (string.IsNullOrWhiteSpace(s.OutputPath))
throw new ArgumentException("Select an output file.");
if (s.DurationSeconds <= 0)
throw new ArgumentException("Duration must be greater than zero.");
if (string.IsNullOrWhiteSpace(s.FontFile) || !File.Exists(s.FontFile))
throw new FileNotFoundException("Select a bold TrueType/OpenType font file.");
}
internal static void ValidatePreview(ShortSettings s)
{
if (string.IsNullOrWhiteSpace(s.InputFile) || !File.Exists(s.InputFile))
throw new FileNotFoundException("Select a valid source video for the Short.");
if (s.DurationSeconds <= 0)
throw new ArgumentException("Duration must be greater than zero.");
if (s.Style == ShortStyle.Brand && (string.IsNullOrWhiteSpace(s.BackgroundImage) || !File.Exists(s.BackgroundImage)))
throw new FileNotFoundException("The brand style requires a background image.");
if (s.Style == ShortStyle.Video && (string.IsNullOrWhiteSpace(s.BackgroundVideo) || !File.Exists(s.BackgroundVideo)))
@@ -169,8 +242,11 @@ public sealed class ShortsPipeline
internal static string BuildFilterComplex(
string baseFilter, string fontEsc, string hookEsc, string titleEsc, string metaEsc, string websiteEsc,
double hookEnd, double infoStart, double ctaStart)
double hookEnd, double infoStart, double ctaStart, bool includeTextOverlays = true)
{
if (!includeTextOverlays)
return baseFilter + ";[base]fps=50,format=yuv420p[vout]";
string hookEndStr = F(hookEnd);
string infoStartStr = F(infoStart);
string ctaStartStr = F(ctaStart);
@@ -186,8 +262,94 @@ public sealed class ShortsPipeline
"fps=50,format=yuv420p[vout]";
}
internal static List<string> BuildInputArguments(ShortSettings settings, double clipDuration, string? seek = null)
{
List<string> args =
[
"-hide_banner",
"-y",
"-ss",
seek ?? settings.Start,
"-t",
F(clipDuration),
"-i",
settings.InputFile,
];
if (settings.Style == ShortStyle.Brand)
args.AddRange(["-loop", "1", "-i", settings.BackgroundImage]);
if (settings.Style == ShortStyle.Video)
args.AddRange(["-stream_loop", "-1", "-i", settings.BackgroundVideo]);
return args;
}
internal static (double HookEnd, double InfoStart, double CtaStart) PreviewOverlayWindows(ShortPreviewShot shot, double duration)
{
if (shot == ShortPreviewShot.Opening)
return (Math.Max(duration, 1), duration + 1, duration + 1);
return (-1, 0, 0);
}
internal static double PreviewSeekSeconds(ShortPreviewShot shot, double duration)
{
if (shot == ShortPreviewShot.Opening)
return Math.Min(1.2, Math.Max(0, duration * 0.2));
return Math.Max(0, duration - 0.4);
}
internal static string AddTimestampOffset(string start, double seconds)
{
if (!TimeSpan.TryParse(start, CultureInfo.InvariantCulture, out TimeSpan parsed))
parsed = TimeSpan.Zero;
if (seconds < 0)
seconds = 0;
return (parsed + TimeSpan.FromSeconds(seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
}
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
private async Task RenderPreviewStillAsync(
ToolPaths tools,
ShortSettings settings,
string baseFilter,
bool includeText,
string hookFile,
string titleFile,
string metaFile,
string websiteFile,
ShortPreviewShot shot,
string outputPath,
Action<string> log,
CancellationToken token)
{
(double hookEnd, double infoStart, double ctaStart) = PreviewOverlayWindows(shot, settings.DurationSeconds);
string filterComplex = BuildFilterComplex(
baseFilter,
fontEsc: includeText ? EscapeFfmpegPath(settings.FontFile) : "",
hookEsc: EscapeFfmpegPath(hookFile),
titleEsc: EscapeFfmpegPath(titleFile),
metaEsc: EscapeFfmpegPath(metaFile),
websiteEsc: EscapeFfmpegPath(websiteFile),
hookEnd,
infoStart,
ctaStart,
includeTextOverlays: includeText);
string seek = AddTimestampOffset(settings.Start, PreviewSeekSeconds(shot, settings.DurationSeconds));
List<string> args = BuildInputArguments(settings, 0.12, seek);
args.AddRange([
"-filter_complex", filterComplex,
"-map", "[vout]",
"-frames:v", "1",
"-update", "1",
outputPath,
]);
log($"Preview {shot.ToString().ToLowerInvariant()} frame...");
await _runner.RunAsync(tools.Ffmpeg, args, log, null, token);
}
internal static string ApplySourceFade(string baseFilter, double fadeInSeconds)
{
if (fadeInSeconds <= 0)