From 323324d713a771fa441bfa2a74fe01fc8957c548 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 15 Aug 2026 21:08:36 +0200 Subject: [PATCH] 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. --- .../Models/SourceCropSelectionTests.cs | 100 ++++++++++ AmiReel.Tests/Services/ShortsPipelineTests.cs | 124 ++++++++++++ Controls/SourceCropEditor.xaml | 65 +++++++ Controls/SourceCropEditor.xaml.cs | 174 +++++++++++++++++ MainPage.xaml | 84 +++++++- MainPage.xaml.cs | 120 ++++++++++++ Models/ShortSettings.cs | 10 + Models/SourceCropSelection.cs | 111 +++++++++++ Services/ShortsPipeline.cs | 182 +++++++++++++++++- 9 files changed, 958 insertions(+), 12 deletions(-) create mode 100644 AmiReel.Tests/Models/SourceCropSelectionTests.cs create mode 100644 Controls/SourceCropEditor.xaml create mode 100644 Controls/SourceCropEditor.xaml.cs create mode 100644 Models/SourceCropSelection.cs diff --git a/AmiReel.Tests/Models/SourceCropSelectionTests.cs b/AmiReel.Tests/Models/SourceCropSelectionTests.cs new file mode 100644 index 0000000..d14d027 --- /dev/null +++ b/AmiReel.Tests/Models/SourceCropSelectionTests.cs @@ -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); + } +} diff --git a/AmiReel.Tests/Services/ShortsPipelineTests.cs b/AmiReel.Tests/Services/ShortsPipelineTests.cs index 59deb24..fe4ecd0 100644 --- a/AmiReel.Tests/Services/ShortsPipelineTests.cs +++ b/AmiReel.Tests/Services/ShortsPipelineTests.cs @@ -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(() => 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 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 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)); + } } diff --git a/Controls/SourceCropEditor.xaml b/Controls/SourceCropEditor.xaml new file mode 100644 index 0000000..2e6368c --- /dev/null +++ b/Controls/SourceCropEditor.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + +