From 8a734d58d9cd7dda46e5c7402335a817d9225b60 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sat, 15 Aug 2026 17:59:18 +0200 Subject: [PATCH] Add Shorts wizard and v4 video-style pipeline Turn the Shorts tab into a step-by-step wizard and port amigadb-short-fx-v4.sh: Video background style, auto/manual crop, source fade-in, and blur/opacity/margin settings. --- AmiReel.Tests/Models/ShortWizardTests.cs | 92 ++ AmiReel.Tests/Services/ShortsPipelineTests.cs | 169 ++++ App.xaml | 6 + MainPage.xaml | 332 ++++--- MainPage.xaml.cs | 268 +++++- Models/AppSettings.cs | 20 + Models/ShortSettings.cs | 22 +- Models/ShortWizard.cs | 63 ++ Services/ShortsPipeline.cs | 180 +++- amigadb-short-fx-v4.sh | 904 ++++++++++++++++++ 10 files changed, 1911 insertions(+), 145 deletions(-) create mode 100644 AmiReel.Tests/Models/ShortWizardTests.cs create mode 100644 Models/ShortWizard.cs create mode 100644 amigadb-short-fx-v4.sh diff --git a/AmiReel.Tests/Models/ShortWizardTests.cs b/AmiReel.Tests/Models/ShortWizardTests.cs new file mode 100644 index 0000000..c8719cd --- /dev/null +++ b/AmiReel.Tests/Models/ShortWizardTests.cs @@ -0,0 +1,92 @@ +using AmiReel.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Models; + +[TestClass] +public class ShortWizardTests +{ + [TestMethod] + public void StepsFor_BrandStyle_IncludesEffectSettingsPage() + { + // Act + IReadOnlyList steps = ShortWizard.StepsFor(ShortStyle.Brand); + + // Assert + CollectionAssert.AreEqual( + new[] + { + ShortWizardStep.Clip, + ShortWizardStep.Effect, + ShortWizardStep.StyleOptions, + ShortWizardStep.Titles, + ShortWizardStep.Encode, + }, + steps.ToArray()); + } + + [TestMethod] + public void StepsFor_VideoStyle_IncludesEffectSettingsPage() + { + // Act + IReadOnlyList steps = ShortWizard.StepsFor(ShortStyle.Video); + + // Assert + Assert.IsTrue(steps.Contains(ShortWizardStep.StyleOptions)); + Assert.AreEqual(5, steps.Count); + } + + [TestMethod] + public void StepsFor_CropStyle_SkipsEffectSettingsPage() + { + // Act + IReadOnlyList steps = ShortWizard.StepsFor(ShortStyle.Crop); + + // Assert + CollectionAssert.AreEqual( + new[] + { + ShortWizardStep.Clip, + ShortWizardStep.Effect, + ShortWizardStep.Titles, + ShortWizardStep.Encode, + }, + steps.ToArray()); + } + + [TestMethod] + public void NeedsStyleOptions_OnlyBrandAndVideo_ReturnsTrue() + { + // Assert + Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Brand)); + Assert.IsTrue(ShortWizard.NeedsStyleOptions(ShortStyle.Video)); + Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Pixel)); + Assert.IsFalse(ShortWizard.NeedsStyleOptions(ShortStyle.Spectrum)); + } + + [TestMethod] + public void Description_ForEveryStyle_IsNonEmpty() + { + foreach (ShortStyle style in Enum.GetValues()) + { + // Act + string description = ShortWizard.Description(style); + + // Assert + Assert.IsFalse(string.IsNullOrWhiteSpace(description), $"{style} is missing a description."); + } + } + + [TestMethod] + public void Title_ForEveryStep_IsNonEmpty() + { + foreach (ShortWizardStep step in Enum.GetValues()) + { + // Act + string title = ShortWizard.Title(step); + + // Assert + Assert.IsFalse(string.IsNullOrWhiteSpace(title), $"{step} is missing a title."); + } + } +} diff --git a/AmiReel.Tests/Services/ShortsPipelineTests.cs b/AmiReel.Tests/Services/ShortsPipelineTests.cs index 941f950..59deb24 100644 --- a/AmiReel.Tests/Services/ShortsPipelineTests.cs +++ b/AmiReel.Tests/Services/ShortsPipelineTests.cs @@ -186,6 +186,74 @@ public class ShortsPipelineTests ShortsPipeline.Validate(settings); } + [TestMethod] + public void Validate_VideoStyleWithoutBackgroundVideo_ThrowsFileNotFoundException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.mp4"); + File.WriteAllText(input, "data"); + string font = Path.Combine(directory, "font.ttf"); + File.WriteAllText(font, "data"); + ShortSettings settings = ValidSettings(input, font, ShortStyle.Video); + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_VideoStyleWithBackgroundVideo_DoesNotThrow() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.mp4"); + File.WriteAllText(input, "data"); + string font = Path.Combine(directory, "font.ttf"); + File.WriteAllText(font, "data"); + string background = Path.Combine(directory, "bg.mp4"); + File.WriteAllText(background, "data"); + ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with { BackgroundVideo = background }; + + // Act & Assert (no exception) + ShortsPipeline.Validate(settings); + } + + [TestMethod] + public void Validate_VideoOpacityAboveOne_ThrowsArgumentException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.mp4"); + File.WriteAllText(input, "data"); + string font = Path.Combine(directory, "font.ttf"); + File.WriteAllText(font, "data"); + string background = Path.Combine(directory, "bg.mp4"); + File.WriteAllText(background, "data"); + ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with + { + BackgroundVideo = background, + VideoOpacity = 1.2, + }; + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_SourceMarginTooWide_ThrowsArgumentException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.mp4"); + File.WriteAllText(input, "data"); + string font = Path.Combine(directory, "font.ttf"); + File.WriteAllText(font, "data"); + ShortSettings settings = ValidSettings(input, font) with { SourceMarginX = 540 }; + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + [TestMethod] public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag() { @@ -220,4 +288,105 @@ public class ShortsPipelineTests StringAssert.Contains(result, "enable='gte(t,26.5)'"); StringAssert.Contains(result, "[vout]"); } + + [TestMethod] + public void GetBaseFilter_VideoStyle_UsesBackgroundVideoInput() + { + // Act + string filter = ShortsPipeline.GetBaseFilter(ShortStyle.Video); + + // Assert + StringAssert.Contains(filter, "[1:v]"); + StringAssert.Contains(filter, "colorchannelmixer=aa=1"); + StringAssert.Contains(filter, "[base]"); + Assert.IsFalse(filter.Contains("gblur=", StringComparison.Ordinal)); + } + + [TestMethod] + public void BuildVideoBaseFilter_WithBlur_InsertsGblur() + { + // Act + string filter = ShortsPipeline.BuildVideoBaseFilter( + "crop=iw:ih:0:0", "scale=1000:-2:flags=lanczos", blur: 8, opacity: 0.65); + + // Assert + StringAssert.Contains(filter, "gblur=sigma=8"); + StringAssert.Contains(filter, "colorchannelmixer=aa=0.65"); + StringAssert.Contains(filter, "crop=iw:ih:0:0,scale=1000:-2:flags=lanczos"); + } + + [TestMethod] + public void ApplySourceFade_WhenFadeIsZero_ReturnsOriginalFilter() + { + // Arrange + const string baseFilter = "[0:v]split=2[bg][fg]"; + + // Act + string result = ShortsPipeline.ApplySourceFade(baseFilter, 0); + + // Assert + Assert.AreEqual(baseFilter, result); + } + + [TestMethod] + public void ApplySourceFade_WhenFadeIsPositive_RewritesSourceLabel() + { + // Act + string result = ShortsPipeline.ApplySourceFade("[0:v]split=2[bg][fg];[0:a]anull[a]", 1.5); + + // Assert + StringAssert.StartsWith(result, "[0:v]format=rgba,fade=t=in:st=0:d=1.5:alpha=1[srcfade];"); + StringAssert.Contains(result, "[srcfade]split=2[bg][fg]"); + StringAssert.Contains(result, "[0:a]anull[a]"); + Assert.IsFalse(result.Contains("[0:v]split", StringComparison.Ordinal)); + } + + [TestMethod] + public void SelectMostCommonCropSpec_PicksTheMostFrequentSpec() + { + // Arrange + const string output = + "crop=800:480:100:0\n" + + "crop=780:480:120:0\n" + + "crop=780:480:120:0\n" + + "crop=760:480:140:0"; + + // Act + string? spec = ShortsPipeline.SelectMostCommonCropSpec(output); + + // Assert + Assert.AreEqual("crop=780:480:120:0", spec); + } + + [TestMethod] + public void TryBuildAutoHorizontalCrop_AppliesEvenSafetyInset() + { + // Act + bool ok = ShortsPipeline.TryBuildAutoHorizontalCrop(800, 100, out int width, out int x); + + // Assert + Assert.IsTrue(ok); + Assert.AreEqual(792, width); + Assert.AreEqual(104, x); + } + + [TestMethod] + public void BuildManualCropFilter_UsesEdgeInsets() + { + // Act + string filter = ShortsPipeline.BuildManualCropFilter(120, 80, 10, 20); + + // Assert + Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter); + } + + [TestMethod] + public void SourceTargetWidth_SubtractsBothMargins() + { + // Act + int width = ShortsPipeline.SourceTargetWidth(40); + + // Assert + Assert.AreEqual(1000, width); + } } diff --git a/App.xaml b/App.xaml index d853aa0..0788879 100644 --- a/App.xaml +++ b/App.xaml @@ -181,6 +181,12 @@ + +