From c41246977367b8aa8e8dd887473284f5e87a54fb Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Thu, 13 Aug 2026 14:27:57 +0200 Subject: [PATCH] Add unit tests, dedupe FFmpeg output filtering, harden settings records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the suggestions from the last review pass: - Add AmiReel.Tests (MSTest), covering the pure logic in Models/ and Services/: SupportedVideoFormats, AppSettings normalization, UserFacingErrors.Summarize, the new FfmpegProgressParser/ FfmpegOutputFilter, RenderPipeline.Validate, BuildProgressMessage, and MoveSourceVideos. 59 tests, all passing. UI code-behind and anything that spawns an actual FFmpeg process are left to manual/integration testing. Exclude AmiReel.Tests\**\*.cs from AmiReel.csproj's default item glob — it's a subfolder of the app project now, so without the exclude the app itself was compiling the MSTest-only test files. - Extract the FFmpeg version/library-banner boilerplate list that ProcessRunner (live log filter) and UserFacingErrors (error summarizer) had each duplicated into a shared FfmpegOutputFilter.IsBoilerplateLine; each caller keeps its own remaining context-specific checks on top. Also extract ProcessRunner's line-parsing regexes into a standalone FfmpegProgressParser so it's directly unit-testable without spawning a process. - Convert AppSettings and RenderSettings from positional record constructors to named `required` init properties. Both records had runs of same-typed consecutive parameters (three string timing fields in AppSettings; three doubles then five ints in RenderSettings) that a positional constructor would let get silently transposed at a call site without the compiler catching it. Update the two call sites (MainPage.xaml.cs) to object-initializer syntax. - Add Properties/AssemblyInfo.cs with InternalsVisibleTo("AmiReel.Tests") and make Validate/BuildProgressMessage/IsNoise internal so tests can reach them directly instead of only through process-spawning entry points. - README: document `dotnet test`, and note that Package.appxmanifest's Identity is a local-dev placeholder that needs a real publisher/cert before MSIX distribution. Verified: dotnet build (solution + test project) is 0 warnings/errors, dotnet test is 59/59 passing, and the app still launches unchanged. Co-Authored-By: Claude Sonnet 5 --- AmiReel.Tests/AmiReel.Tests.csproj | 20 ++ AmiReel.Tests/Models/AppSettingsTests.cs | 107 +++++++++ .../Models/SupportedVideoFormatsTests.cs | 69 ++++++ .../Services/FfmpegOutputFilterTests.cs | 48 ++++ .../Services/FfmpegProgressParserTests.cs | 70 ++++++ AmiReel.Tests/Services/RenderPipelineTests.cs | 216 ++++++++++++++++++ .../Services/UserFacingErrorsTests.cs | 120 ++++++++++ AmiReel.csproj | 9 + AmiReel.slnx | 1 + MainPage.xaml.cs | 56 ++--- Models/AppSettings.cs | 90 ++++---- Models/RenderSettings.cs | 46 ++-- Properties/AssemblyInfo.cs | 3 + README.md | 17 ++ Services/FfmpegOutputFilter.cs | 40 ++++ Services/FfmpegProgressParser.cs | 44 ++++ Services/ProcessRunner.cs | 71 +----- Services/RenderPipeline.cs | 4 +- Services/UserFacingErrors.cs | 28 +-- 19 files changed, 885 insertions(+), 174 deletions(-) create mode 100644 AmiReel.Tests/AmiReel.Tests.csproj create mode 100644 AmiReel.Tests/Models/AppSettingsTests.cs create mode 100644 AmiReel.Tests/Models/SupportedVideoFormatsTests.cs create mode 100644 AmiReel.Tests/Services/FfmpegOutputFilterTests.cs create mode 100644 AmiReel.Tests/Services/FfmpegProgressParserTests.cs create mode 100644 AmiReel.Tests/Services/RenderPipelineTests.cs create mode 100644 AmiReel.Tests/Services/UserFacingErrorsTests.cs create mode 100644 Properties/AssemblyInfo.cs create mode 100644 Services/FfmpegOutputFilter.cs create mode 100644 Services/FfmpegProgressParser.cs diff --git a/AmiReel.Tests/AmiReel.Tests.csproj b/AmiReel.Tests/AmiReel.Tests.csproj new file mode 100644 index 0000000..370bd91 --- /dev/null +++ b/AmiReel.Tests/AmiReel.Tests.csproj @@ -0,0 +1,20 @@ + + + net10.0-windows10.0.26100.0 + 10.0.17763.0 + enable + enable + false + false + + + + + + + + + + + + diff --git a/AmiReel.Tests/Models/AppSettingsTests.cs b/AmiReel.Tests/Models/AppSettingsTests.cs new file mode 100644 index 0000000..10a2021 --- /dev/null +++ b/AmiReel.Tests/Models/AppSettingsTests.cs @@ -0,0 +1,107 @@ +using AmiReel.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Models; + +[TestClass] +public class AppSettingsTests +{ + [TestMethod] + public void Default_SetsFallbackOutputFolderAndSensibleDefaults() + { + // Arrange + const string outputFolder = @"C:\Videos"; + + // Act + AppSettings settings = AppSettings.Default(outputFolder); + + // Assert + Assert.AreEqual(outputFolder, settings.OutputFolder); + Assert.AreEqual("Dark", settings.Theme); + Assert.AreEqual("Auto", settings.Encoder); + Assert.IsTrue(settings.ShouldMoveSourcesToOriginals); + } + + [TestMethod] + public void ShouldMoveSourcesToOriginals_WhenFlagIsNull_DefaultsToTrue() + { + // Arrange + AppSettings settings = AppSettings.Default(@"C:\Videos") with { MoveSourcesToOriginals = null }; + + // Act + bool result = settings.ShouldMoveSourcesToOriginals; + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + public void ShouldMoveSourcesToOriginals_WhenFlagIsExplicitlyFalse_ReturnsFalse() + { + // Arrange + AppSettings settings = AppSettings.Default(@"C:\Videos") with { MoveSourcesToOriginals = false }; + + // Act + bool result = settings.ShouldMoveSourcesToOriginals; + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void Normalize_WithBlankOutputFolder_UsesFallback() + { + // Arrange + AppSettings settings = AppSettings.Default("") with { OutputFolder = " " }; + + // Act + AppSettings normalized = settings.Normalize(@"C:\Fallback"); + + // Assert + Assert.AreEqual(@"C:\Fallback", normalized.OutputFolder); + } + + [TestMethod] + public void Normalize_WithBlankTimingValues_RestoresDefaults() + { + // Arrange + AppSettings settings = AppSettings.Default(@"C:\Videos") with + { + TrimStart = "", + FadeSeconds = " ", + EndCardHoldSeconds = "", + ThumbnailInterval = "", + }; + + // Act + AppSettings normalized = settings.Normalize(@"C:\Videos"); + + // Assert + Assert.AreEqual("4.414", normalized.TrimStart); + Assert.AreEqual("3", normalized.FadeSeconds); + Assert.AreEqual("4", normalized.EndCardHoldSeconds); + Assert.AreEqual("10", normalized.ThumbnailInterval); + } + + [TestMethod] + public void Normalize_WithPopulatedValues_KeepsThemUnchanged() + { + // Arrange + AppSettings settings = AppSettings.Default(@"C:\Videos") with + { + TrimStart = "1.5", + FadeSeconds = "2.5", + Theme = "Light", + Encoder = "NvidiaNvenc", + }; + + // Act + AppSettings normalized = settings.Normalize(@"C:\Videos"); + + // Assert + Assert.AreEqual("1.5", normalized.TrimStart); + Assert.AreEqual("2.5", normalized.FadeSeconds); + Assert.AreEqual("Light", normalized.Theme); + Assert.AreEqual("NvidiaNvenc", normalized.Encoder); + } +} diff --git a/AmiReel.Tests/Models/SupportedVideoFormatsTests.cs b/AmiReel.Tests/Models/SupportedVideoFormatsTests.cs new file mode 100644 index 0000000..e3fee32 --- /dev/null +++ b/AmiReel.Tests/Models/SupportedVideoFormatsTests.cs @@ -0,0 +1,69 @@ +using AmiReel.Models; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Models; + +[TestClass] +public class SupportedVideoFormatsTests +{ + [TestMethod] + [DataRow(@"C:\videos\clip.avi")] + [DataRow(@"C:\videos\clip.mp4")] + [DataRow(@"C:\videos\clip.MOV")] + [DataRow(@"C:\videos\clip.mkv")] + [DataRow(@"C:\videos\clip.webm")] + public void IsSupported_WithAcceptedExtension_ReturnsTrue(string path) + { + // Act + bool result = SupportedVideoFormats.IsSupported(path); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow(@"C:\documents\report.pdf")] + [DataRow(@"C:\images\photo.png")] + [DataRow(@"C:\audio\track.mp3")] + public void IsSupported_WithUnsupportedExtension_ReturnsFalse(string path) + { + // Act + bool result = SupportedVideoFormats.IsSupported(path); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void IsSupported_IsCaseInsensitive() + { + // Arrange + const string lower = @"C:\videos\clip.avi"; + const string upper = @"C:\videos\clip.AVI"; + + // Act & Assert + Assert.IsTrue(SupportedVideoFormats.IsSupported(lower)); + Assert.IsTrue(SupportedVideoFormats.IsSupported(upper)); + } + + [TestMethod] + public void IsSupported_WithNoExtension_ReturnsFalse() + { + // Act + bool result = SupportedVideoFormats.IsSupported(@"C:\videos\clip"); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void PickerFilter_ContainsEveryExtensionAsAGlobPattern() + { + // Act + string filter = SupportedVideoFormats.PickerFilter; + + // Assert + foreach (string extension in SupportedVideoFormats.Extensions) + StringAssert.Contains(filter, "*" + extension); + } +} diff --git a/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs b/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs new file mode 100644 index 0000000..3cf5369 --- /dev/null +++ b/AmiReel.Tests/Services/FfmpegOutputFilterTests.cs @@ -0,0 +1,48 @@ +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class FfmpegOutputFilterTests +{ + [TestMethod] + [DataRow("ffmpeg version 6.0-full_build-www.gyan.dev")] + [DataRow("built with gcc 12.2.0")] + [DataRow("configuration: --enable-gpl")] + [DataRow("libavutil 58. 2.100 / 58. 2.100")] + [DataRow("Input #0, avi, from 'clip.avi':")] + [DataRow("Output #0, mp4, to 'out.mp4':")] + [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%")] + public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line) + { + // Act + bool result = FfmpegOutputFilter.IsBoilerplateLine(line); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("Error while opening encoder for output stream #0:0")] + [DataRow("Cannot load nvcuda.dll")] + [DataRow("Unknown encoder 'h264_nvenc'")] + public void IsBoilerplateLine_WithRealErrorText_ReturnsFalse(string line) + { + // Act + bool result = FfmpegOutputFilter.IsBoilerplateLine(line); + + // Assert + Assert.IsFalse(result); + } + + [TestMethod] + public void IsBoilerplateLine_IsCaseInsensitive() + { + // Act & Assert + Assert.IsTrue(FfmpegOutputFilter.IsBoilerplateLine("DURATION: 00:00:01.00")); + Assert.IsTrue(FfmpegOutputFilter.IsBoilerplateLine("duration: 00:00:01.00")); + } +} diff --git a/AmiReel.Tests/Services/FfmpegProgressParserTests.cs b/AmiReel.Tests/Services/FfmpegProgressParserTests.cs new file mode 100644 index 0000000..bbef5fa --- /dev/null +++ b/AmiReel.Tests/Services/FfmpegProgressParserTests.cs @@ -0,0 +1,70 @@ +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class FfmpegProgressParserTests +{ + [TestMethod] + public void Parse_WithFullProgressLine_ExtractsAllFields() + { + // Arrange + const string line = "frame= 1234 fps=49.8 q=-1.0 size= 102400kB time=00:00:24.68 bitrate=33987.6kbits/s speed=0.996x"; + + // Act + FfmpegOutputLine result = FfmpegProgressParser.Parse(line); + + // Assert + Assert.AreEqual(new TimeSpan(0, 0, 0, 24, 680), result.Time); + Assert.AreEqual(1234, result.Frame); + Assert.AreEqual(49.8, result.FramesPerSecond); + Assert.AreEqual(0.996, result.Speed); + } + + [TestMethod] + public void Parse_WithNoRecognizedFields_ReturnsAllNulls() + { + // Arrange + const string line = "Metadata:"; + + // Act + FfmpegOutputLine result = FfmpegProgressParser.Parse(line); + + // Assert + Assert.IsNull(result.Time); + Assert.IsNull(result.Frame); + Assert.IsNull(result.FramesPerSecond); + Assert.IsNull(result.Speed); + Assert.AreEqual(line, result.Line); + } + + [TestMethod] + public void Parse_WithOnlyTimeField_ExtractsTimeAndLeavesFrameFieldsNull() + { + // Arrange: has a "time=" field but no matching frame=/fps=/speed=x group + const string line = "size= 1024kB time=00:01:02.03 bitrate= 100.0kbits/s"; + + // Act + FfmpegOutputLine result = FfmpegProgressParser.Parse(line); + + // Assert + Assert.AreEqual(new TimeSpan(0, 0, 1, 2, 30), result.Time); + Assert.IsNull(result.Frame); + Assert.IsNull(result.FramesPerSecond); + Assert.IsNull(result.Speed); + } + + [TestMethod] + public void Parse_PreservesOriginalLineText() + { + // Arrange + const string line = "some arbitrary ffmpeg output"; + + // Act + FfmpegOutputLine result = FfmpegProgressParser.Parse(line); + + // Assert + Assert.AreEqual(line, result.Line); + } +} diff --git a/AmiReel.Tests/Services/RenderPipelineTests.cs b/AmiReel.Tests/Services/RenderPipelineTests.cs new file mode 100644 index 0000000..f3f824f --- /dev/null +++ b/AmiReel.Tests/Services/RenderPipelineTests.cs @@ -0,0 +1,216 @@ +using AmiReel.Models; +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class RenderPipelineTests +{ + 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 RenderSettings ValidSettings(IReadOnlyList inputFiles, string outputDirectory, string endCardPath = "") => new() + { + InputFiles = inputFiles, + EndCardPath = endCardPath, + OutputDirectory = outputDirectory, + OutputName = "output", + FfmpegPath = "", + FfprobePath = "", + Encoder = EncoderMode.Auto, + TrimStart = 0, + FadeSeconds = 1, + EndCardHoldSeconds = 1, + ThumbnailInterval = 10, + }; + + [TestMethod] + public void Validate_WithNoInputFiles_ThrowsArgumentException() + { + // Arrange + RenderSettings settings = ValidSettings([], @"C:\out"); + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithMissingInputFile_ThrowsFileNotFoundException() + { + // Arrange + string directory = CreateTempDirectory(); + RenderSettings settings = ValidSettings([Path.Combine(directory, "missing.avi")], directory); + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithMissingEndCard_ThrowsFileNotFoundException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.avi"); + File.WriteAllText(input, "data"); + RenderSettings settings = ValidSettings([input], directory, endCardPath: Path.Combine(directory, "missing.png")); + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithBlankOutputDirectory_ThrowsArgumentException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.avi"); + File.WriteAllText(input, "data"); + RenderSettings settings = ValidSettings([input], ""); + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithInvalidOutputName_ThrowsArgumentException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.avi"); + File.WriteAllText(input, "data"); + RenderSettings settings = ValidSettings([input], directory) with { OutputName = "bad" + Path.GetInvalidFileNameChars()[0] }; + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithThumbnailIntervalLessThanOne_ThrowsArgumentException() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.avi"); + File.WriteAllText(input, "data"); + RenderSettings settings = ValidSettings([input], directory) with { ThumbnailInterval = 0 }; + + // Act & Assert + Assert.ThrowsExactly(() => RenderPipeline.Validate(settings)); + } + + [TestMethod] + public void Validate_WithAllRequirementsMet_DoesNotThrow() + { + // Arrange + string directory = CreateTempDirectory(); + string input = Path.Combine(directory, "clip.avi"); + File.WriteAllText(input, "data"); + RenderSettings settings = ValidSettings([input], directory); + + // Act & Assert (no exception) + RenderPipeline.Validate(settings); + } + + [TestMethod] + public void BuildProgressMessage_WithOnlyTime_ReturnsTimeRangeOnly() + { + // Act + string message = RenderPipeline.BuildProgressMessage(TimeSpan.FromSeconds(30), 60, null, null, null, 50); + + // Assert + Assert.AreEqual("00:00:30 / 00:01:00", message); + } + + [TestMethod] + public void BuildProgressMessage_WithAllOptionalFields_IncludesFpsSpeedAndFrame() + { + // Arrange: numbers render using the current culture (this is user-facing text), + // so build the expected fragments the same way rather than hard-coding "." / ",". + const double fps = 49.8; + const double speed = 0.99; + const int frame = 1500; + const int totalFrames = 3000; // ceil(60s * 50fps) + + // Act + string message = RenderPipeline.BuildProgressMessage(TimeSpan.FromSeconds(30), 60, fps, speed, frame, 50); + + // Assert + StringAssert.Contains(message, "00:00:30 / 00:01:00"); + StringAssert.Contains(message, $"{fps:0.#} fps"); + StringAssert.Contains(message, $"{speed:0.##}x"); + StringAssert.Contains(message, $"frame {frame:N0} / {totalFrames:N0}"); + } + + [TestMethod] + public void MoveSourceVideos_WithNewDestination_MovesFileIntoOriginalsSubfolder() + { + // Arrange + string outputDirectory = CreateTempDirectory(); + string sourceDirectory = CreateTempDirectory(); + string source = Path.Combine(sourceDirectory, "clip.avi"); + File.WriteAllText(source, "data"); + List logs = []; + + // Act + IReadOnlyList result = RenderPipeline.MoveSourceVideos([source], outputDirectory, logs.Add); + + // Assert + string expected = Path.Combine(outputDirectory, "originals", "clip.avi"); + Assert.AreEqual(expected, result.Single()); + Assert.IsTrue(File.Exists(expected)); + Assert.IsFalse(File.Exists(source)); + } + + [TestMethod] + public void MoveSourceVideos_WithDuplicateFilename_AppendsNumericSuffix() + { + // Arrange + string outputDirectory = CreateTempDirectory(); + string sourceDirectory = CreateTempDirectory(); + string sourceA = Path.Combine(sourceDirectory, "a", "clip.avi"); + string sourceB = Path.Combine(sourceDirectory, "b", "clip.avi"); + Directory.CreateDirectory(Path.GetDirectoryName(sourceA)!); + Directory.CreateDirectory(Path.GetDirectoryName(sourceB)!); + File.WriteAllText(sourceA, "data-a"); + File.WriteAllText(sourceB, "data-b"); + + // Act + IReadOnlyList result = RenderPipeline.MoveSourceVideos([sourceA, sourceB], outputDirectory, _ => { }); + + // Assert + Assert.AreEqual(2, result.Distinct(StringComparer.OrdinalIgnoreCase).Count()); + Assert.IsTrue(result.All(File.Exists)); + } + + [TestMethod] + public void MoveSourceVideos_WithMissingSourceFile_LogsWarningAndKeepsOriginalPath() + { + // Arrange + string outputDirectory = CreateTempDirectory(); + string missingSource = Path.Combine(CreateTempDirectory(), "gone.avi"); + List logs = []; + + // Act + IReadOnlyList result = RenderPipeline.MoveSourceVideos([missingSource], outputDirectory, logs.Add); + + // Assert + Assert.AreEqual(missingSource, result.Single()); + Assert.IsTrue(logs.Any(line => line.Contains("no longer exists", StringComparison.OrdinalIgnoreCase))); + } +} diff --git a/AmiReel.Tests/Services/UserFacingErrorsTests.cs b/AmiReel.Tests/Services/UserFacingErrorsTests.cs new file mode 100644 index 0000000..2bfe6c0 --- /dev/null +++ b/AmiReel.Tests/Services/UserFacingErrorsTests.cs @@ -0,0 +1,120 @@ +using AmiReel.Services; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace AmiReel.Tests.Services; + +[TestClass] +public class UserFacingErrorsTests +{ + [TestMethod] + public void Summarize_WithBlankMessage_ReturnsGenericMessage() + { + // Arrange + Exception exception = new(" "); + + // Act + string result = UserFacingErrors.Summarize(exception); + + // Assert + Assert.AreEqual("An unexpected error occurred.", result); + } + + [TestMethod] + public void Summarize_WithNonProcessExitMessage_ReturnsMessageUnchanged() + { + // Arrange + Exception exception = new("The output directory could not be created."); + + // Act + string result = UserFacingErrors.Summarize(exception); + + // Assert + Assert.AreEqual("The output directory could not be created.", result); + } + + [TestMethod] + public void Summarize_WithProcessExitAndOnlyBoilerplateLines_ReturnsFirstLineOnly() + { + // Arrange + Exception exception = new( + "Process exited with code 1.\n" + + "ffmpeg version 6.0\n" + + "built with gcc\n" + + "Input #0, avi, from 'clip.avi':\n" + + "Duration: 00:01:00.00\n"); + + // Act + string result = UserFacingErrors.Summarize(exception); + + // Assert + Assert.AreEqual("Process exited with code 1.", result); + } + + [TestMethod] + public void Summarize_WithProcessExitAndRealErrorLines_IncludesTheRealErrorLines() + { + // Arrange + Exception exception = new( + "Process exited with code 1.\n" + + "ffmpeg version 6.0\n" + + "[h264_nvenc @ 0x1] Cannot load libnvidia-encode.so.1\n" + + "Error initializing output stream 0:0 -- Error while opening encoder\n"); + + // Act + string result = UserFacingErrors.Summarize(exception); + + // Assert + StringAssert.Contains(result, "Process exited with code 1."); + StringAssert.Contains(result, "Cannot load libnvidia-encode.so.1"); + StringAssert.Contains(result, "Error while opening encoder"); + } + + [TestMethod] + public void Summarize_WithMoreThanThreeErrorLines_KeepsOnlyTheLastThree() + { + // Arrange + Exception exception = new( + "Process exited with code 1.\n" + + "error line 1\n" + + "error line 2\n" + + "error line 3\n" + + "error line 4\n"); + + // Act + string result = UserFacingErrors.Summarize(exception); + string[] lines = result.Split(Environment.NewLine); + + // Assert + Assert.AreEqual(4, lines.Length); + CollectionAssert.DoesNotContain(lines, "error line 1"); + CollectionAssert.Contains(lines, "error line 4"); + } + + [TestMethod] + [DataRow("ffmpeg version 6.0")] + [DataRow("built with gcc 12")] + [DataRow("Input #0, avi, from 'clip.avi':")] + [DataRow("Duration: 00:01:00.00, start: 0.000000")] + [DataRow("Stream #0:0: Video: mjpeg")] + public void IsNoise_WithFfmpegBoilerplate_ReturnsTrue(string line) + { + // Act + bool result = UserFacingErrors.IsNoise(line); + + // Assert + Assert.IsTrue(result); + } + + [TestMethod] + [DataRow("Error while opening encoder for output stream")] + [DataRow("Cannot load libnvidia-encode.so.1")] + [DataRow("No such file or directory")] + public void IsNoise_WithRealErrorText_ReturnsFalse(string line) + { + // Act + bool result = UserFacingErrors.IsNoise(line); + + // Assert + Assert.IsFalse(result); + } +} diff --git a/AmiReel.csproj b/AmiReel.csproj index 4c8b47f..43c76f4 100644 --- a/AmiReel.csproj +++ b/AmiReel.csproj @@ -19,6 +19,15 @@ enable + + + + + + + diff --git a/AmiReel.slnx b/AmiReel.slnx index c12d831..397c3a4 100644 --- a/AmiReel.slnx +++ b/AmiReel.slnx @@ -1,3 +1,4 @@ + diff --git a/MainPage.xaml.cs b/MainPage.xaml.cs index ce1ff7c..3f211b9 100644 --- a/MainPage.xaml.cs +++ b/MainPage.xaml.cs @@ -222,19 +222,21 @@ public sealed partial class MainPage : Page throw new ArgumentException("Thumbnail interval must be at least one second."); EncoderMode encoder = Enum.Parse(SelectedEncoder()); - return new( - _inputs.ToList(), - EndCardBox.Text.Trim(), - OutputFolderBox.Text.Trim(), - OutputNameBox.Text.Trim(), - FfmpegPathBox.Text.Trim(), - FfprobePathBox.Text.Trim(), - encoder, - Number(TrimBox.Text, "trim start"), - Number(FadeBox.Text, "fade"), - Number(HoldBox.Text, "end-card hold"), - interval, - MoveSourcesBox.IsChecked == true); + return new RenderSettings + { + InputFiles = _inputs.ToList(), + EndCardPath = EndCardBox.Text.Trim(), + OutputDirectory = OutputFolderBox.Text.Trim(), + OutputName = OutputNameBox.Text.Trim(), + FfmpegPath = FfmpegPathBox.Text.Trim(), + FfprobePath = FfprobePathBox.Text.Trim(), + Encoder = encoder, + TrimStart = Number(TrimBox.Text, "trim start"), + FadeSeconds = Number(FadeBox.Text, "fade"), + EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"), + ThumbnailInterval = interval, + MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true, + }; } private void ReplaceInputs(IReadOnlyList paths) @@ -325,19 +327,21 @@ public sealed partial class MainPage : Page private void SaveSettings() { - AppSettingsStore.Save(new AppSettings( - OutputFolderBox.Text.Trim(), - EndCardBox.Text.Trim(), - FfmpegPathBox.Text.Trim(), - FfprobePathBox.Text.Trim(), - PreviewPlayerPathBox.Text.Trim(), - SelectedTheme(), - SelectedEncoder(), - TrimBox.Text.Trim(), - FadeBox.Text.Trim(), - HoldBox.Text.Trim(), - IntervalBox.Text.Trim(), - MoveSourcesBox.IsChecked == true)); + AppSettingsStore.Save(new AppSettings + { + OutputFolder = OutputFolderBox.Text.Trim(), + EndCardPath = EndCardBox.Text.Trim(), + FfmpegPath = FfmpegPathBox.Text.Trim(), + FfprobePath = FfprobePathBox.Text.Trim(), + PreviewPlayerPath = PreviewPlayerPathBox.Text.Trim(), + Theme = SelectedTheme(), + Encoder = SelectedEncoder(), + TrimStart = TrimBox.Text.Trim(), + FadeSeconds = FadeBox.Text.Trim(), + EndCardHoldSeconds = HoldBox.Text.Trim(), + ThumbnailInterval = IntervalBox.Text.Trim(), + MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true, + }); } private string SelectedTheme() => (ThemeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Dark"; diff --git a/Models/AppSettings.cs b/Models/AppSettings.cs index 4b2fbfe..80d1979 100644 --- a/Models/AppSettings.cs +++ b/Models/AppSettings.cs @@ -1,46 +1,58 @@ namespace AmiReel.Models; -public sealed record AppSettings( - string OutputFolder, - string EndCardPath, - string FfmpegPath, - string FfprobePath, - string PreviewPlayerPath, - string Theme, - string Encoder, - string TrimStart, - string FadeSeconds, - string EndCardHoldSeconds, - string ThumbnailInterval, - bool? MoveSourcesToOriginals) +/// +/// Persisted user settings (%LOCALAPPDATA%\AmiReel\settings.json). Uses named init +/// properties rather than a positional constructor: several members share the same +/// type (e.g. TrimStart/FadeSeconds/EndCardHoldSeconds), so a +/// positional record would let two arguments be silently transposed at a call site +/// without the compiler catching it. +/// +public sealed record AppSettings { + public required string OutputFolder { get; init; } + public required string EndCardPath { get; init; } + public required string FfmpegPath { get; init; } + public required string FfprobePath { get; init; } + public required string PreviewPlayerPath { get; init; } + public required string Theme { get; init; } + public required string Encoder { get; init; } + public required string TrimStart { get; init; } + public required string FadeSeconds { get; init; } + public required string EndCardHoldSeconds { get; init; } + public required string ThumbnailInterval { get; init; } + public bool? MoveSourcesToOriginals { get; init; } + public bool ShouldMoveSourcesToOriginals => MoveSourcesToOriginals != false; - public static AppSettings Default(string outputFolder) => new( - outputFolder, - "", - "", - "", - "", - "Dark", - "Auto", - "4.414", - "3", - "4", - "10", - true); + public static AppSettings Default(string outputFolder) => new() + { + OutputFolder = outputFolder, + EndCardPath = "", + FfmpegPath = "", + FfprobePath = "", + PreviewPlayerPath = "", + Theme = "Dark", + Encoder = "Auto", + TrimStart = "4.414", + FadeSeconds = "3", + EndCardHoldSeconds = "4", + ThumbnailInterval = "10", + MoveSourcesToOriginals = true, + }; - public AppSettings Normalize(string fallbackOutputFolder) => new( - string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder, - EndCardPath ?? "", - FfmpegPath ?? "", - FfprobePath ?? "", - PreviewPlayerPath ?? "", - string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme, - string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder, - string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart, - string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds, - string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds, - string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval, - MoveSourcesToOriginals ?? true); + public AppSettings Normalize(string fallbackOutputFolder) => this with + { + OutputFolder = string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder, + EndCardPath = EndCardPath ?? "", + FfmpegPath = FfmpegPath ?? "", + FfprobePath = FfprobePath ?? "", + PreviewPlayerPath = PreviewPlayerPath ?? "", + Theme = string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme, + Encoder = string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder, + TrimStart = string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart, + FadeSeconds = string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds, + EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds, + ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval, + MoveSourcesToOriginals = MoveSourcesToOriginals ?? true, + }; } diff --git a/Models/RenderSettings.cs b/Models/RenderSettings.cs index 099452f..3e90d8f 100644 --- a/Models/RenderSettings.cs +++ b/Models/RenderSettings.cs @@ -7,23 +7,33 @@ public enum EncoderMode CpuX264 } -public sealed record RenderSettings( - IReadOnlyList InputFiles, - string EndCardPath, - string OutputDirectory, - string OutputName, - string FfmpegPath, - string FfprobePath, - EncoderMode Encoder, - double TrimStart, - double FadeSeconds, - double EndCardHoldSeconds, - int ThumbnailInterval, - bool MoveSourcesToOriginals = true, - int Width = 3840, - int Height = 2160, - int FramesPerSecond = 50, - int ThumbnailWidth = 1280, - int ThumbnailHeight = 720); +/// +/// One render job's parameters. Uses named init properties rather than a positional +/// constructor: TrimStart/FadeSeconds/EndCardHoldSeconds are three consecutive +/// members (and Width/Height/FramesPerSecond/ThumbnailWidth/ +/// ThumbnailHeight five consecutive members), so a positional +/// record would let arguments be silently transposed at a call site without the +/// compiler catching it. +/// +public sealed record RenderSettings +{ + public required IReadOnlyList InputFiles { get; init; } + public required string EndCardPath { get; init; } + public required string OutputDirectory { get; init; } + public required string OutputName { get; init; } + public required string FfmpegPath { get; init; } + public required string FfprobePath { get; init; } + public required EncoderMode Encoder { get; init; } + public required double TrimStart { get; init; } + public required double FadeSeconds { get; init; } + public required double EndCardHoldSeconds { get; init; } + public required int ThumbnailInterval { get; init; } + public bool MoveSourcesToOriginals { get; init; } = true; + public int Width { get; init; } = 3840; + public int Height { get; init; } = 2160; + public int FramesPerSecond { get; init; } = 50; + public int ThumbnailWidth { get; init; } = 1280; + public int ThumbnailHeight { get; init; } = 720; +} public sealed record RenderProgress(double Percent, string Stage, string Message); diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..875282d --- /dev/null +++ b/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("AmiReel.Tests")] diff --git a/README.md b/README.md index cc431c9..4e6effe 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,19 @@ dotnet build .\AmiReel.csproj dotnet run --project .\AmiReel.csproj ``` +## Run Tests + +Unit tests live in `AmiReel.Tests/` (MSTest), covering the pure logic in `Models/` and +`Services/` — settings normalization, supported-format detection, FFmpeg output parsing/ +filtering, render-settings validation, and moving source files into `originals/`. + +```powershell +dotnet test .\AmiReel.Tests\AmiReel.Tests.csproj +``` + +UI code-behind (`App`, `MainWindow`, `MainPage`) and anything that spawns an actual FFmpeg +process are intentionally left to manual/integration testing rather than unit tests. + ## Publish ```powershell @@ -141,6 +154,10 @@ successful render. attribution required by that distribution. - Windows Explorer may cache executable icons. If a freshly published build still shows an old icon, rename the file or refresh the icon cache before assuming the embed failed. +- `Package.appxmanifest` currently has a placeholder `Identity` (a random GUID `Name` and + `Publisher="CN=AppPublisher"`). That's fine for local unpackaged builds, but before signing + an MSIX for real distribution, replace them with a real publisher identity and generate a + matching signing certificate (`winapp cert generate`, see `AGENTS.md`). ## Troubleshooting diff --git a/Services/FfmpegOutputFilter.cs b/Services/FfmpegOutputFilter.cs new file mode 100644 index 0000000..bc943ba --- /dev/null +++ b/Services/FfmpegOutputFilter.cs @@ -0,0 +1,40 @@ +namespace AmiReel.Services; + +/// +/// Recognizes the FFmpeg/FFprobe startup banner and stream-info boilerplate that both the +/// live log filter () and the error summarizer +/// () want to hide — the version/library banner, input/output +/// stream dumps, and the final size/duration summary line are never useful to a user. +/// +public static class FfmpegOutputFilter +{ + private static readonly string[] BoilerplatePrefixes = + [ + "ffmpeg version ", + "built with ", + "configuration:", + "libavutil", + "libavcodec", + "libavformat", + "libavdevice", + "libavfilter", + "libswscale", + "libswresample", + "Input #", + "Output #", + "Stream mapping:", + "Stream #", + "Metadata:", + "Duration:", + "Press [q] to stop", + "video:", + "audio:", + "subtitle:", + "other streams:", + "global headers:", + "muxing overhead:", + ]; + + public static bool IsBoilerplateLine(string line) => + BoilerplatePrefixes.Any(prefix => line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)); +} diff --git a/Services/FfmpegProgressParser.cs b/Services/FfmpegProgressParser.cs new file mode 100644 index 0000000..7c67958 --- /dev/null +++ b/Services/FfmpegProgressParser.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using System.Text.RegularExpressions; + +namespace AmiReel.Services; + +public sealed record FfmpegOutputLine(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame); + +/// +/// Parses a single line of FFmpeg stdout/stderr for the `time=`/`frame=`/`fps=`/`speed=` +/// progress fields FFmpeg prints while encoding. +/// +public static partial class FfmpegProgressParser +{ + public static FfmpegOutputLine Parse(string line) + { + TimeSpan? time = null; + double? fps = null; + double? speed = null; + int? frame = null; + + Match timeMatch = TimeRegex().Match(line); + if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime)) + time = parsedTime; + + Match frameMatch = FrameRegex().Match(line); + if (frameMatch.Success) + { + if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame)) + frame = parsedFrame; + if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps)) + fps = parsedFps; + if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed)) + speed = parsedSpeed; + } + + return new FfmpegOutputLine(line, time, fps, speed, frame); + } + + [GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)] + private static partial Regex TimeRegex(); + + [GeneratedRegex(@"frame=\s*(?\d+).*?fps=\s*(?\d+(?:\.\d+)?).*?speed=\s*(?\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)] + private static partial Regex FrameRegex(); +} diff --git a/Services/ProcessRunner.cs b/Services/ProcessRunner.cs index a26ace3..b6191ae 100644 --- a/Services/ProcessRunner.cs +++ b/Services/ProcessRunner.cs @@ -1,15 +1,11 @@ using System.Diagnostics; -using System.Globalization; using System.Text; -using System.Text.RegularExpressions; using System.IO; namespace AmiReel.Services; -public sealed partial class ProcessRunner +public sealed class ProcessRunner { - public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame); - public async Task RunAsync( string executable, IEnumerable arguments, @@ -17,7 +13,7 @@ public sealed partial class ProcessRunner Action? position, CancellationToken token, bool allowFailure = false, - Action? outputHandler = null) + Action? outputHandler = null) { ProcessStartInfo start = new() { @@ -51,12 +47,12 @@ public sealed partial class ProcessRunner private static async Task PumpAsync( StreamReader reader, StringBuilder output, Action? log, - Action? position, CancellationToken token, Action? outputHandler) + Action? position, CancellationToken token, Action? outputHandler) { while (await reader.ReadLineAsync(token) is { } line) { output.AppendLine(line); - ProcessOutput parsed = ParseOutput(line); + FfmpegOutputLine parsed = FfmpegProgressParser.Parse(line); if (!IsNoise(parsed)) log?.Invoke(line); if (parsed.Time is { } time) @@ -65,32 +61,7 @@ public sealed partial class ProcessRunner } } - private static ProcessOutput ParseOutput(string line) - { - TimeSpan? time = null; - double? fps = null; - double? speed = null; - int? frame = null; - - Match timeMatch = TimeRegex().Match(line); - if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime)) - time = parsedTime; - - Match frameMatch = FrameRegex().Match(line); - if (frameMatch.Success) - { - if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame)) - frame = parsedFrame; - if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps)) - fps = parsedFps; - if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed)) - speed = parsedSpeed; - } - - return new ProcessOutput(line, time, fps, speed, frame); - } - - private static bool IsNoise(ProcessOutput output) + internal static bool IsNoise(FfmpegOutputLine output) { string line = output.Line.TrimStart(); if (string.IsNullOrWhiteSpace(line)) @@ -111,39 +82,11 @@ public sealed partial class ProcessRunner || line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase)) return false; - return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase) + return FfmpegOutputFilter.IsBoilerplateLine(line) || line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("title :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("CPB properties:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("[", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("video:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase); + || line.StartsWith("[", StringComparison.OrdinalIgnoreCase); } - - [GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)] - private static partial Regex TimeRegex(); - - [GeneratedRegex(@"frame=\s*(?\d+).*?fps=\s*(?\d+(?:\.\d+)?).*?speed=\s*(?\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)] - private static partial Regex FrameRegex(); } diff --git a/Services/RenderPipeline.cs b/Services/RenderPipeline.cs index b286b9d..7dd4c26 100644 --- a/Services/RenderPipeline.cs +++ b/Services/RenderPipeline.cs @@ -255,7 +255,7 @@ public sealed class RenderPipeline }); } - private static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame, int framesPerSecond) + internal static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame, int framesPerSecond) { TimeSpan total = TimeSpan.FromSeconds(durationSeconds); List parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"]; @@ -358,7 +358,7 @@ public sealed class RenderPipeline } } - private static void Validate(RenderSettings s) + internal static void Validate(RenderSettings s) { if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file."); if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist."); diff --git a/Services/UserFacingErrors.cs b/Services/UserFacingErrors.cs index 69008b4..cc6e5ab 100644 --- a/Services/UserFacingErrors.cs +++ b/Services/UserFacingErrors.cs @@ -39,38 +39,16 @@ public static class UserFacingErrors return string.Join(Environment.NewLine, summary); } - private static bool IsNoise(string line) + internal static bool IsNoise(string line) { - return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase) + return FfmpegOutputFilter.IsBoilerplateLine(line) || line.StartsWith("major_brand", StringComparison.OrdinalIgnoreCase) || line.StartsWith("minor_version", StringComparison.OrdinalIgnoreCase) || line.StartsWith("compatible_brands", StringComparison.OrdinalIgnoreCase) || line.StartsWith("encoder", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[in#", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[out#", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Last message repeated", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("frame=", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("video:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase) - || line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase); + || line.StartsWith("frame=", StringComparison.OrdinalIgnoreCase); } }