From a590904e89c85369cdc74d75ad8f2e32315f7194 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 16 Aug 2026 09:20:56 +0200 Subject: [PATCH] Add video preview, source crop, find-videos, and render toasts Bring the Video tab closer to Shorts with 4K preview stills and crop modes (none, auto, center, plus the visual editor). Settings store a default source folder so Find videos can collect output.avi sequences. Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a Windows notification when a video or short finishes or fails. --- AmiReel.Tests/Models/AppSettingsTests.cs | 49 ++++ .../Services/FfmpegOutputFilterTests.cs | 2 + AmiReel.Tests/Services/ProcessRunnerTests.cs | 34 +++ AmiReel.Tests/Services/RenderNotifierTests.cs | 67 +++++ AmiReel.Tests/Services/RenderPipelineTests.cs | 148 +++++++++++ AmiReel.Tests/Services/ShortsPipelineTests.cs | 47 ++++ .../Services/SourceRecordingFinderTests.cs | 99 +++++++ App.xaml.cs | 4 +- MainPage.xaml | 134 +++++++++- MainPage.xaml.cs | 245 +++++++++++++++++- Models/AppSettings.cs | 18 ++ Models/RenderSettings.cs | 15 ++ Models/ShortSettings.cs | 3 +- Package.appxmanifest | 16 +- Services/FfmpegOutputFilter.cs | 1 + Services/RenderNotifier.cs | 66 +++++ Services/RenderPipeline.cs | 146 ++++++++++- Services/ShortsPipeline.cs | 113 +++----- Services/SourceCropFilters.cs | 113 ++++++++ Services/SourceRecordingFinder.cs | 42 +++ 20 files changed, 1259 insertions(+), 103 deletions(-) create mode 100644 AmiReel.Tests/Services/ProcessRunnerTests.cs create mode 100644 AmiReel.Tests/Services/RenderNotifierTests.cs create mode 100644 AmiReel.Tests/Services/SourceRecordingFinderTests.cs create mode 100644 Services/RenderNotifier.cs create mode 100644 Services/SourceCropFilters.cs create mode 100644 Services/SourceRecordingFinder.cs diff --git a/AmiReel.Tests/Models/AppSettingsTests.cs b/AmiReel.Tests/Models/AppSettingsTests.cs index 10a2021..6670aa1 100644 --- a/AmiReel.Tests/Models/AppSettingsTests.cs +++ b/AmiReel.Tests/Models/AppSettingsTests.cs @@ -81,6 +81,55 @@ public class AppSettingsTests Assert.AreEqual("3", normalized.FadeSeconds); Assert.AreEqual("4", normalized.EndCardHoldSeconds); Assert.AreEqual("10", normalized.ThumbnailInterval); + Assert.AreEqual("", normalized.SourceSearchFolder); + Assert.AreEqual("Auto", normalized.VideoSourceCropMode); + Assert.AreEqual("0", normalized.VideoCropLeft); + } + + [TestMethod] + public void Normalize_WithBlankVideoSourceSettings_RestoresDefaults() + { + // Arrange + AppSettings settings = AppSettings.Default(@"C:\Videos") with + { + SourceSearchFolder = null!, + VideoSourceCropMode = "", + VideoCropLeft = " ", + VideoCropRight = "", + VideoCropTop = "", + VideoCropBottom = "", + }; + + // Act + AppSettings normalized = settings.Normalize(@"C:\Videos"); + + // Assert + Assert.AreEqual("", normalized.SourceSearchFolder); + Assert.AreEqual("Auto", normalized.VideoSourceCropMode); + Assert.AreEqual("0", normalized.VideoCropLeft); + Assert.AreEqual("0", normalized.VideoCropRight); + Assert.AreEqual("0", normalized.VideoCropTop); + Assert.AreEqual("0", normalized.VideoCropBottom); + } + + [TestMethod] + public void Normalize_WithSourceSearchFolder_KeepsItUnchanged() + { + // Arrange + AppSettings settings = AppSettings.Default(@"C:\Videos") with + { + SourceSearchFolder = @"D:\AmigaIntrosVideos", + VideoSourceCropMode = "Center", + VideoCropLeft = "12", + }; + + // Act + AppSettings normalized = settings.Normalize(@"C:\Videos"); + + // Assert + Assert.AreEqual(@"D:\AmigaIntrosVideos", normalized.SourceSearchFolder); + Assert.AreEqual("Center", normalized.VideoSourceCropMode); + Assert.AreEqual("12", normalized.VideoCropLeft); } [TestMethod] diff --git a/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs b/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs index 3cf5369..cbb82fd 100644 --- a/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs +++ b/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs @@ -16,6 +16,8 @@ public class FfmpegOutputFilterTests [DataRow("Stream #0:0: Video: mjpeg")] [DataRow("Duration: 00:01:00.00, start: 0.000000, bitrate: 128 kb/s")] [DataRow("video:1024kB audio:128kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.5%")] + [DataRow("Last message repeated 1 times")] + [DataRow("Last message repeated 24 times")] public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line) { // Act diff --git a/AmiReel.Tests/Services/ProcessRunnerTests.cs b/AmiReel.Tests/Services/ProcessRunnerTests.cs new file mode 100644 index 0000000..7b12a46 --- /dev/null +++ b/AmiReel.Tests/Services/ProcessRunnerTests.cs @@ -0,0 +1,34 @@ +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class ProcessRunnerTests +{ + [TestMethod] + public void IsNoise_WithLastMessageRepeated_ReturnsTrue() + { + // Arrange + FfmpegOutputLine line = FfmpegProgressParser.Parse("Last message repeated 1 times"); + + // Act + bool result = ProcessRunner.IsNoise(line); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void IsNoise_WithCropdetectSpec_ReturnsFalse() + { + // Arrange + FfmpegOutputLine line = FfmpegProgressParser.Parse("crop=672:ih:56:0"); + + // Act + bool result = ProcessRunner.IsNoise(line); + + // Assert + Assert.IsFalse(result); + } +} diff --git a/AmiReel.Tests/Services/RenderNotifierTests.cs b/AmiReel.Tests/Services/RenderNotifierTests.cs new file mode 100644 index 0000000..d4a79a0 --- /dev/null +++ b/AmiReel.Tests/Services/RenderNotifierTests.cs @@ -0,0 +1,67 @@ +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class RenderNotifierTests +{ + [TestMethod] + public void DescribeCompleted_ForVideo_UsesRenderCompleteCopy() + { + // Act + (string title, string body) = RenderNotifier.DescribeCompleted(shorts: false); + + // Assert + Assert.AreEqual("Render complete", title); + StringAssert.Contains(body, "successfully"); + } + + [TestMethod] + public void DescribeCompleted_ForShorts_UsesShortCompleteCopy() + { + // Act + (string title, string body) = RenderNotifier.DescribeCompleted(shorts: true); + + // Assert + Assert.AreEqual("Short complete", title); + StringAssert.Contains(body, "Short"); + } + + [TestMethod] + public void DescribeFailed_WithBlankSummary_UsesFallbackBody() + { + // Act + (string title, string body) = RenderNotifier.DescribeFailed(" "); + + // Assert + Assert.AreEqual("Render failed", title); + Assert.AreEqual("The render failed.", body); + } + + [TestMethod] + public void DescribeFailed_WithLongSummary_TruncatesBody() + { + // Arrange + string summary = new('x', 250); + + // Act + (string title, string body) = RenderNotifier.DescribeFailed(summary); + + // Assert + Assert.AreEqual("Render failed", title); + Assert.AreEqual(180, body.Length); + StringAssert.EndsWith(body, "..."); + } + + [TestMethod] + public void DescribeCancelled_ReturnsCancelledCopy() + { + // Act + (string title, string body) = RenderNotifier.DescribeCancelled(); + + // Assert + Assert.AreEqual("Render cancelled", title); + StringAssert.Contains(body, "cancelled"); + } +} diff --git a/AmiReel.Tests/Services/RenderPipelineTests.cs b/AmiReel.Tests/Services/RenderPipelineTests.cs index f3f824f..083e528 100644 --- a/AmiReel.Tests/Services/RenderPipelineTests.cs +++ b/AmiReel.Tests/Services/RenderPipelineTests.cs @@ -127,6 +127,154 @@ public class RenderPipelineTests RenderPipeline.Validate(settings); } + [TestMethod] + public void ValidatePreview_WithMissingOutputName_DoesNotThrow() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.avi"); + File.WriteAllText(input, "data"); + RenderSettings settings = ValidSettings([input], "") with { OutputName = "" }; + + // Act & Assert (no exception) + RenderPipeline.ValidatePreview(settings); + } + + [TestMethod] + public void ValidatePreview_WithNoInputFiles_ThrowsArgumentException() + { + // Arrange + RenderSettings settings = ValidSettings([], @"C:\out"); + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.ValidatePreview(settings)); + } + + [TestMethod] + public void BuildJoinVideoFilter_IncludesCropThenScaleAndPad() + { + // Arrange + RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out"); + + // Act + string filter = RenderPipeline.BuildJoinVideoFilter(settings, "crop=iw-10-10:ih-4-4:10:4"); + + // Assert + StringAssert.StartsWith(filter, "crop=iw-10-10:ih-4-4:10:4,scale=2880:2160"); + StringAssert.Contains(filter, "pad=3840:2160"); + } + + [TestMethod] + public void BuildSourceCropFilter_WithManualInsets_UsesEdgeCrop() + { + // Arrange + RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with + { + CropLeft = 20, + CropRight = 10, + CropTop = 4, + CropBottom = 6, + }; + + // Act + string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); + + // Assert + Assert.AreEqual("crop=iw-20-10:ih-4-6:20:4", filter); + } + + [TestMethod] + public void BuildSourceCropFilter_WithAutoDetection_UsesDetectedFilter() + { + // Arrange + RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with + { + SourceCropMode = ShortSourceCropMode.Auto, + }; + + // Act + string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); + + // Assert + Assert.AreEqual("crop=800:ih:40:0", filter); + } + + [TestMethod] + public void BuildSourceCropFilter_WithCenterMode_UsesFourByThreeCrop() + { + // Arrange + RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with + { + SourceCropMode = ShortSourceCropMode.Center, + }; + + // Act + string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); + + // Assert + Assert.AreEqual(ShortsPipeline.DefaultSourceCropFilter, filter); + } + + [TestMethod] + public void BuildSourceCropFilter_WithNoneMode_AppliesNoCrop() + { + // Arrange + RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with + { + SourceCropMode = ShortSourceCropMode.None, + }; + + // Act + string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0"); + + // Assert + Assert.AreEqual("", filter); + } + + [TestMethod] + public void BuildJoinVideoFilter_WithNoCrop_StartsAtScale() + { + // Arrange + RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out"); + + // Act + string filter = RenderPipeline.BuildJoinVideoFilter(settings, ""); + + // Assert + StringAssert.StartsWith(filter, "scale=2880:2160"); + Assert.IsFalse(filter.Contains("crop=", StringComparison.Ordinal)); + } + + [TestMethod] + public void PreviewSeekSeconds_WithDurationAfterTrim_StaysInsideRemainingClip() + { + // Act + double seek = RenderPipeline.PreviewSeekSeconds(4, 30); + + // Assert + Assert.AreEqual(5.2, seek, 0.001); + } + + [TestMethod] + public void PreviewSeekSeconds_WhenTrimIsPastDuration_ReturnsTrimStart() + { + // Act + double seek = RenderPipeline.PreviewSeekSeconds(40, 10); + + // Assert + Assert.AreEqual(40, seek, 0.001); + } + + [TestMethod] + public void FormatTimestamp_FormatsInvariantHoursMinutesSeconds() + { + // Act + string stamp = RenderPipeline.FormatTimestamp(4.414); + + // Assert + Assert.AreEqual("00:00:04.414", stamp); + } + [TestMethod] public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly() { diff --git a/AmiReel.Tests/Services/ShortsPipelineTests.cs b/AmiReel.Tests/Services/ShortsPipelineTests.cs index c0fc463..889db5e 100644 --- a/AmiReel.Tests/Services/ShortsPipelineTests.cs +++ b/AmiReel.Tests/Services/ShortsPipelineTests.cs @@ -380,6 +380,53 @@ public class ShortsPipelineTests Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter); } + [TestMethod] + public void SourceCropFiltersResolve_WithManualInsets_IgnoresAutoDetection() + { + // Act + string filter = SourceCropFilters.Resolve(ShortSourceCropMode.Auto, 8, 4, 2, 1, "crop=800:ih:40:0"); + + // Assert + Assert.AreEqual("crop=iw-8-4:ih-2-1:8:2", filter); + } + + [TestMethod] + public void SourceCropFiltersResolve_WithNoneMode_ReturnsEmptyFilter() + { + // Act + string filter = SourceCropFilters.Resolve(ShortSourceCropMode.None, 0, 0, 0, 0, "crop=800:ih:40:0"); + + // Assert + Assert.AreEqual("", filter); + } + + [TestMethod] + public void BuildVideoBaseFilter_WithNoCrop_ScalesTheFullSource() + { + // Act + string filter = ShortsPipeline.BuildVideoBaseFilter("", "scale=1000:-2:flags=lanczos", blur: 0, opacity: 1); + + // Assert + StringAssert.Contains(filter, "[0:v]scale=1000:-2:flags=lanczos,setsar=1[fg]"); + Assert.IsFalse(filter.Contains("[0:v]crop=", StringComparison.Ordinal)); + } + + [TestMethod] + public void TryBuildAutoHorizontalFilter_UsesMostCommonSpecAndEvenInset() + { + // Arrange + const string output = + "crop=800:480:100:0\n" + + "crop=800:480:100:0\n" + + "crop=760:480:140:0"; + + // Act + string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output); + + // Assert + Assert.AreEqual("crop=792:ih:104:0", filter); + } + [TestMethod] public void SourceTargetWidth_SubtractsBothMargins() { diff --git a/AmiReel.Tests/Services/SourceRecordingFinderTests.cs b/AmiReel.Tests/Services/SourceRecordingFinderTests.cs new file mode 100644 index 0000000..205d0d1 --- /dev/null +++ b/AmiReel.Tests/Services/SourceRecordingFinderTests.cs @@ -0,0 +1,99 @@ +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class SourceRecordingFinderTests +{ + 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; + } + + [TestMethod] + public void FindSequentialOutputs_WithMissingFolder_ReturnsEmpty() + { + // Act + IReadOnlyList result = SourceRecordingFinder.FindSequentialOutputs( + Path.Combine(Path.GetTempPath(), "AmiReelTests", "missing-" + Guid.NewGuid().ToString("N"))); + + // Assert + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void FindSequentialOutputs_WithBlankFolder_ReturnsEmpty() + { + // Act + IReadOnlyList result = SourceRecordingFinder.FindSequentialOutputs(" "); + + // Assert + Assert.AreEqual(0, result.Count); + } + + [TestMethod] + public void FindSequentialOutputs_WithOnlyOutputAvi_ReturnsSingleFile() + { + // Arrange + string directory = CreateTempDirectory(); + string first = Path.Combine(directory, "output.avi"); + File.WriteAllText(first, "one"); + + // Act + IReadOnlyList result = SourceRecordingFinder.FindSequentialOutputs(directory); + + // Assert + Assert.AreEqual(1, result.Count); + Assert.AreEqual(first, result[0]); + } + + [TestMethod] + public void FindSequentialOutputs_WithNumberedSequence_ReturnsFilesUntilFirstGap() + { + // Arrange + string directory = CreateTempDirectory(); + File.WriteAllText(Path.Combine(directory, "output.avi"), "0"); + File.WriteAllText(Path.Combine(directory, "output_1.avi"), "1"); + File.WriteAllText(Path.Combine(directory, "output_2.avi"), "2"); + File.WriteAllText(Path.Combine(directory, "output_4.avi"), "skipped"); + File.WriteAllText(Path.Combine(directory, "output.mp4"), "wrong-ext"); + + // Act + IReadOnlyList result = SourceRecordingFinder.FindSequentialOutputs(directory); + + // Assert + Assert.AreEqual(3, result.Count); + Assert.AreEqual("output.avi", Path.GetFileName(result[0])); + Assert.AreEqual("output_1.avi", Path.GetFileName(result[1])); + Assert.AreEqual("output_2.avi", Path.GetFileName(result[2])); + } + + [TestMethod] + public void FindSequentialOutputs_WithoutBaseOutputAvi_ReturnsEmptyEvenIfNumberedExists() + { + // Arrange + string directory = CreateTempDirectory(); + File.WriteAllText(Path.Combine(directory, "output_1.avi"), "1"); + + // Act + IReadOnlyList result = SourceRecordingFinder.FindSequentialOutputs(directory); + + // Assert + Assert.AreEqual(0, result.Count); + } +} diff --git a/App.xaml.cs b/App.xaml.cs index 5148757..1a9260c 100644 --- a/App.xaml.cs +++ b/App.xaml.cs @@ -1,4 +1,5 @@ -using Microsoft.UI.Xaml; +using AmiReel.Services; +using Microsoft.UI.Xaml; using WinRT.Interop; namespace AmiReel; @@ -29,6 +30,7 @@ public partial class App : Application { _window = new MainWindow(); MainWindowHandle = WindowNative.GetWindowHandle(_window); + RenderNotifier.Register(); _window.Activate(); } } diff --git a/MainPage.xaml b/MainPage.xaml index dd0b275..8077f85 100644 --- a/MainPage.xaml +++ b/MainPage.xaml @@ -34,14 +34,22 @@ + -