From 58e11ba4e3a4d5f3a22b921c9a9405886f598257 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Thu, 13 Aug 2026 17:30:25 +0200 Subject: [PATCH] Add YouTube Shorts tab (C# port of amigadb-short.sh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New "Video | Shorts" tab strip on the main page. The existing Video tab and its render pipeline are unchanged; Shorts is a new, independent workflow sharing the same render-progress/log/output controls. - Services/ShortsPipeline.cs: C# port of amigadb-short.sh rather than shelling out to bash — end users on plain Windows don't have bash/WSL, and shipping this as a native part of the app keeps distribution self-contained. All 19 visual style filtergraphs (brand, pixel, mirror, crop, workbench, blur, crt, copper, stars, grid, tiles, scan, starfield, plasma, rasterbars, vhs, monitor, split, spectrum) are copied verbatim from the bash heredocs. Per explicit decision, kept parity with the script's actual behavior where the brand-style HOOK_BOX_Y/INFO_BOX_Y/font-size variables are computed but never actually used in the final filter (dead code in the original) rather than "fixing" it and changing rendered output. Reuses ToolExtractor/MediaProbe/ProcessRunner exactly as RenderPipeline does, so FFmpeg resolution and NVENC fallback behave identically. - Models/ShortSettings.cs: job parameters + ShortStyle enum. - AppSettings gains sticky Shorts defaults (style, hook, website, font, background image, CRF, preset) alongside the existing Video defaults. - MainPage: Video/Shorts tab buttons toggle two content panels; the background-image field auto-hides for non-brand styles; font field defaults to the first bold system font found (Segoe UI Bold, Segoe UI Semibold, Arial Bold, Calibri Bold); blank output path is auto-derived from the input filename and style, matching the script's default. - 13 new unit tests (escaping order, meta-line joining, validation, every style producing a non-empty filter, the drawtext trailer's timing gates). Full run is 72/72 passing. - Fixed a bug caught during manual testing: MainPage.Resources["PrimaryButtonStyle"] looked in the Page's own (empty) resource dictionary instead of Application.Current.Resources, throwing on every tab switch. Verified end-to-end: rendered a real Short from an existing video through the actual running app (not just unit tests) and inspected extracted frames — hook/title/meta/website text overlays appear and disappear at the correct timestamps with correct styling. Co-Authored-By: Claude Sonnet 5 --- AmiReel.Tests/Services/ShortsPipelineTests.cs | 223 ++++++ MainPage.xaml | 144 +++- MainPage.xaml.cs | 211 +++++- Models/AppSettings.cs | 16 + Models/ShortSettings.cs | 47 ++ README.md | 41 +- Services/ShortsPipeline.cs | 386 ++++++++++ amigadb-short.sh | 674 ++++++++++++++++++ 8 files changed, 1698 insertions(+), 44 deletions(-) create mode 100644 AmiReel.Tests/Services/ShortsPipelineTests.cs create mode 100644 Models/ShortSettings.cs create mode 100644 Services/ShortsPipeline.cs create mode 100644 amigadb-short.sh diff --git a/AmiReel.Tests/Services/ShortsPipelineTests.cs b/AmiReel.Tests/Services/ShortsPipelineTests.cs new file mode 100644 index 0000000..941f950 --- /dev/null +++ b/AmiReel.Tests/Services/ShortsPipelineTests.cs @@ -0,0 +1,223 @@ +using AmiReel.Models; +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class ShortsPipelineTests +{ + private readonly List _tempDirectories = []; + + [TestCleanup] + public void Cleanup() + { + foreach (string directory in _tempDirectories) + { + try { Directory.Delete(directory, true); } catch { } + } + } + + private string CreateTempDirectory() + { + string directory = Path.Combine(Path.GetTempPath(), "AmiReelTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + _tempDirectories.Add(directory); + return directory; + } + + private static ShortSettings ValidSettings(string input, string font, ShortStyle style = ShortStyle.Crop, string background = "") => new() + { + InputFile = input, + OutputPath = Path.Combine(Path.GetDirectoryName(input) ?? "", "out.mp4"), + Title = "Test Short", + Hook = "THIS RAN ON AN AMIGA", + Website = "AMIGADB.NET", + Start = "00:00:00", + DurationSeconds = 30, + Style = style, + BackgroundImage = background, + FontFile = font, + FfmpegPath = "", + FfprobePath = "", + }; + + [TestMethod] + public void EscapeFfmpegPath_WithBackslashesColonsAndQuotes_EscapesAllThree() + { + // Act + string result = ShortsPipeline.EscapeFfmpegPath(@"C:\Fonts\font's.ttf"); + + // Assert + Assert.AreEqual(@"C\:\\Fonts\\font\'s.ttf", result); + } + + [TestMethod] + public void EscapeFfmpegPath_EscapesBackslashesBeforeIntroducingNewOnes() + { + // Act: if colon/quote escaping ran before backslash escaping, the backslashes + // they introduce would themselves get doubled, which must not happen. + string result = ShortsPipeline.EscapeFfmpegPath("C:\\a"); + + // Assert + Assert.AreEqual("C\\:\\\\a", result); + } + + [TestMethod] + public void BuildMeta_WithAllFieldsPopulated_JoinsWithMiddleDot() + { + // Arrange + ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "1991", Type = "Cracktro" }; + + // Act + string meta = ShortsPipeline.BuildMeta(settings); + + // Assert + Assert.AreEqual("Pirates · 1991 · Cracktro", meta); + } + + [TestMethod] + public void BuildMeta_WithSomeFieldsBlank_SkipsBlankFields() + { + // Arrange + ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "", Type = " " }; + + // Act + string meta = ShortsPipeline.BuildMeta(settings); + + // Assert + Assert.AreEqual("Pirates", meta); + } + + [TestMethod] + public void BuildMeta_WithAllFieldsBlank_ReturnsEmptyString() + { + // Arrange + ShortSettings settings = ValidSettings("in.mp4", "font.ttf"); + + // Act + string meta = ShortsPipeline.BuildMeta(settings); + + // Assert + Assert.AreEqual("", meta); + } + + [TestMethod] + public void Validate_WithMissingInputFile_ThrowsFileNotFoundException() + { + // Arrange + string directory = CreateTempDirectory(); + ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf"); + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithBlankTitle_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 { Title = "" }; + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithZeroDuration_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 { DurationSeconds = 0 }; + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithMissingFontFile_ThrowsFileNotFoundException() + { + // 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")); + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_BrandStyleWithoutBackgroundImage_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.Brand); + + // Act & Assert + Assert.ThrowsExactly(() => ShortsPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_NonBrandStyleWithoutBackgroundImage_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"); + ShortSettings settings = ValidSettings(input, font, ShortStyle.Crop); + + // Act & Assert (no exception) + ShortsPipeline.Validate(settings); + } + + [TestMethod] + public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag() + { + foreach (ShortStyle style in Enum.GetValues()) + { + // Act + string filter = ShortsPipeline.GetBaseFilter(style); + + // Assert + Assert.IsFalse(string.IsNullOrWhiteSpace(filter), $"{style} produced an empty filter."); + StringAssert.Contains(filter, "[base]", $"{style} filter does not label an output as [base]."); + } + } + + [TestMethod] + public void BuildFilterComplex_IncludesAllFourTextOverlaysAndTheirTimingGates() + { + // Act + string result = ShortsPipeline.BuildFilterComplex( + "[0:v]copy[base]", + fontEsc: "C\\:\\\\font.ttf", + hookEsc: "hook.txt", titleEsc: "title.txt", metaEsc: "meta.txt", websiteEsc: "website.txt", + hookEnd: 2.7, infoStart: 23, ctaStart: 26.5); + + // Assert + StringAssert.Contains(result, "textfile='hook.txt'"); + StringAssert.Contains(result, "textfile='title.txt'"); + StringAssert.Contains(result, "textfile='meta.txt'"); + StringAssert.Contains(result, "textfile='website.txt'"); + StringAssert.Contains(result, "enable='between(t,0,2.7)'"); + StringAssert.Contains(result, "enable='gte(t,23)'"); + StringAssert.Contains(result, "enable='gte(t,26.5)'"); + StringAssert.Contains(result, "[vout]"); + } +} diff --git a/MainPage.xaml b/MainPage.xaml index b477e05..bf598f9 100644 --- a/MainPage.xaml +++ b/MainPage.xaml @@ -9,13 +9,19 @@ + - + +