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