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));
}
}