Add unit tests, dedupe FFmpeg output filtering, harden settings records

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-13 14:27:57 +02:00
parent 3de3357d23
commit c412469773
19 changed files with 885 additions and 174 deletions
+20
View File
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<TargetPlatformMinVersion>10.0.17763.0</TargetPlatformMinVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsPublishable>false</IsPublishable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest.TestAdapter" Version="*" />
<PackageReference Include="MSTest.TestFramework" Version="*" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="*" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AmiReel.csproj" />
</ItemGroup>
</Project>
+107
View File
@@ -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);
}
}
@@ -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);
}
}
@@ -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"));
}
}
@@ -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);
}
}
@@ -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<string> _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<string> 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<ArgumentException>(() => 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<FileNotFoundException>(() => 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<FileNotFoundException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<ArgumentException>(() => 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<string> logs = [];
// Act
IReadOnlyList<string> 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<string> 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<string> logs = [];
// Act
IReadOnlyList<string> 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)));
}
}
@@ -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);
}
}
+9
View File
@@ -19,6 +19,15 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<!-- AmiReel.Tests is a subfolder of this project's directory; exclude it from the
default SDK glob so its test files (and MSTest-only usings) aren't compiled
into the app itself. -->
<Compile Remove="AmiReel.Tests\**\*.cs" />
<EmbeddedResource Remove="AmiReel.Tests\**\*" />
<None Remove="AmiReel.Tests\**\*" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="Assets\SplashScreen.scale-200.png" /> <Content Include="Assets\SplashScreen.scale-200.png" />
<Content Include="Assets\LockScreenLogo.scale-200.png" /> <Content Include="Assets\LockScreenLogo.scale-200.png" />
+1
View File
@@ -1,3 +1,4 @@
<Solution> <Solution>
<Project Path="AmiReel.csproj" /> <Project Path="AmiReel.csproj" />
<Project Path="AmiReel.Tests/AmiReel.Tests.csproj" />
</Solution> </Solution>
+30 -26
View File
@@ -222,19 +222,21 @@ public sealed partial class MainPage : Page
throw new ArgumentException("Thumbnail interval must be at least one second."); throw new ArgumentException("Thumbnail interval must be at least one second.");
EncoderMode encoder = Enum.Parse<EncoderMode>(SelectedEncoder()); EncoderMode encoder = Enum.Parse<EncoderMode>(SelectedEncoder());
return new( return new RenderSettings
_inputs.ToList(), {
EndCardBox.Text.Trim(), InputFiles = _inputs.ToList(),
OutputFolderBox.Text.Trim(), EndCardPath = EndCardBox.Text.Trim(),
OutputNameBox.Text.Trim(), OutputDirectory = OutputFolderBox.Text.Trim(),
FfmpegPathBox.Text.Trim(), OutputName = OutputNameBox.Text.Trim(),
FfprobePathBox.Text.Trim(), FfmpegPath = FfmpegPathBox.Text.Trim(),
encoder, FfprobePath = FfprobePathBox.Text.Trim(),
Number(TrimBox.Text, "trim start"), Encoder = encoder,
Number(FadeBox.Text, "fade"), TrimStart = Number(TrimBox.Text, "trim start"),
Number(HoldBox.Text, "end-card hold"), FadeSeconds = Number(FadeBox.Text, "fade"),
interval, EndCardHoldSeconds = Number(HoldBox.Text, "end-card hold"),
MoveSourcesBox.IsChecked == true); ThumbnailInterval = interval,
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
};
} }
private void ReplaceInputs(IReadOnlyList<string> paths) private void ReplaceInputs(IReadOnlyList<string> paths)
@@ -325,19 +327,21 @@ public sealed partial class MainPage : Page
private void SaveSettings() private void SaveSettings()
{ {
AppSettingsStore.Save(new AppSettings( AppSettingsStore.Save(new AppSettings
OutputFolderBox.Text.Trim(), {
EndCardBox.Text.Trim(), OutputFolder = OutputFolderBox.Text.Trim(),
FfmpegPathBox.Text.Trim(), EndCardPath = EndCardBox.Text.Trim(),
FfprobePathBox.Text.Trim(), FfmpegPath = FfmpegPathBox.Text.Trim(),
PreviewPlayerPathBox.Text.Trim(), FfprobePath = FfprobePathBox.Text.Trim(),
SelectedTheme(), PreviewPlayerPath = PreviewPlayerPathBox.Text.Trim(),
SelectedEncoder(), Theme = SelectedTheme(),
TrimBox.Text.Trim(), Encoder = SelectedEncoder(),
FadeBox.Text.Trim(), TrimStart = TrimBox.Text.Trim(),
HoldBox.Text.Trim(), FadeSeconds = FadeBox.Text.Trim(),
IntervalBox.Text.Trim(), EndCardHoldSeconds = HoldBox.Text.Trim(),
MoveSourcesBox.IsChecked == true)); ThumbnailInterval = IntervalBox.Text.Trim(),
MoveSourcesToOriginals = MoveSourcesBox.IsChecked == true,
});
} }
private string SelectedTheme() => (ThemeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Dark"; private string SelectedTheme() => (ThemeBox.SelectedItem as ComboBoxItem)?.Tag?.ToString() ?? "Dark";
+51 -39
View File
@@ -1,46 +1,58 @@
namespace AmiReel.Models; namespace AmiReel.Models;
public sealed record AppSettings( /// <summary>
string OutputFolder, /// Persisted user settings (%LOCALAPPDATA%\AmiReel\settings.json). Uses named init
string EndCardPath, /// properties rather than a positional constructor: several members share the same
string FfmpegPath, /// <see langword="string"/> type (e.g. TrimStart/FadeSeconds/EndCardHoldSeconds), so a
string FfprobePath, /// positional record would let two arguments be silently transposed at a call site
string PreviewPlayerPath, /// without the compiler catching it.
string Theme, /// </summary>
string Encoder, public sealed record AppSettings
string TrimStart,
string FadeSeconds,
string EndCardHoldSeconds,
string ThumbnailInterval,
bool? MoveSourcesToOriginals)
{ {
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 bool ShouldMoveSourcesToOriginals => MoveSourcesToOriginals != false;
public static AppSettings Default(string outputFolder) => new( public static AppSettings Default(string outputFolder) => new()
outputFolder, {
"", OutputFolder = outputFolder,
"", EndCardPath = "",
"", FfmpegPath = "",
"", FfprobePath = "",
"Dark", PreviewPlayerPath = "",
"Auto", Theme = "Dark",
"4.414", Encoder = "Auto",
"3", TrimStart = "4.414",
"4", FadeSeconds = "3",
"10", EndCardHoldSeconds = "4",
true); ThumbnailInterval = "10",
MoveSourcesToOriginals = true,
};
public AppSettings Normalize(string fallbackOutputFolder) => new( public AppSettings Normalize(string fallbackOutputFolder) => this with
string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder, {
EndCardPath ?? "", OutputFolder = string.IsNullOrWhiteSpace(OutputFolder) ? fallbackOutputFolder : OutputFolder,
FfmpegPath ?? "", EndCardPath = EndCardPath ?? "",
FfprobePath ?? "", FfmpegPath = FfmpegPath ?? "",
PreviewPlayerPath ?? "", FfprobePath = FfprobePath ?? "",
string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme, PreviewPlayerPath = PreviewPlayerPath ?? "",
string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder, Theme = string.IsNullOrWhiteSpace(Theme) ? "Dark" : Theme,
string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart, Encoder = string.IsNullOrWhiteSpace(Encoder) ? "Auto" : Encoder,
string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds, TrimStart = string.IsNullOrWhiteSpace(TrimStart) ? "4.414" : TrimStart,
string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds, FadeSeconds = string.IsNullOrWhiteSpace(FadeSeconds) ? "3" : FadeSeconds,
string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval, EndCardHoldSeconds = string.IsNullOrWhiteSpace(EndCardHoldSeconds) ? "4" : EndCardHoldSeconds,
MoveSourcesToOriginals ?? true); ThumbnailInterval = string.IsNullOrWhiteSpace(ThumbnailInterval) ? "10" : ThumbnailInterval,
MoveSourcesToOriginals = MoveSourcesToOriginals ?? true,
};
} }
+28 -18
View File
@@ -7,23 +7,33 @@ public enum EncoderMode
CpuX264 CpuX264
} }
public sealed record RenderSettings( /// <summary>
IReadOnlyList<string> InputFiles, /// One render job's parameters. Uses named init properties rather than a positional
string EndCardPath, /// constructor: TrimStart/FadeSeconds/EndCardHoldSeconds are three consecutive
string OutputDirectory, /// <see langword="double"/> members (and Width/Height/FramesPerSecond/ThumbnailWidth/
string OutputName, /// ThumbnailHeight five consecutive <see langword="int"/> members), so a positional
string FfmpegPath, /// record would let arguments be silently transposed at a call site without the
string FfprobePath, /// compiler catching it.
EncoderMode Encoder, /// </summary>
double TrimStart, public sealed record RenderSettings
double FadeSeconds, {
double EndCardHoldSeconds, public required IReadOnlyList<string> InputFiles { get; init; }
int ThumbnailInterval, public required string EndCardPath { get; init; }
bool MoveSourcesToOriginals = true, public required string OutputDirectory { get; init; }
int Width = 3840, public required string OutputName { get; init; }
int Height = 2160, public required string FfmpegPath { get; init; }
int FramesPerSecond = 50, public required string FfprobePath { get; init; }
int ThumbnailWidth = 1280, public required EncoderMode Encoder { get; init; }
int ThumbnailHeight = 720); 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); public sealed record RenderProgress(double Percent, string Stage, string Message);
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AmiReel.Tests")]
+17
View File
@@ -74,6 +74,19 @@ dotnet build .\AmiReel.csproj
dotnet run --project .\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 ## Publish
```powershell ```powershell
@@ -141,6 +154,10 @@ successful render.
attribution required by that distribution. attribution required by that distribution.
- Windows Explorer may cache executable icons. If a freshly published build still shows an old - 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. 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 ## Troubleshooting
+40
View File
@@ -0,0 +1,40 @@
namespace AmiReel.Services;
/// <summary>
/// Recognizes the FFmpeg/FFprobe startup banner and stream-info boilerplate that both the
/// live log filter (<see cref="ProcessRunner"/>) and the error summarizer
/// (<see cref="UserFacingErrors"/>) want to hide — the version/library banner, input/output
/// stream dumps, and the final size/duration summary line are never useful to a user.
/// </summary>
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));
}
+44
View File
@@ -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);
/// <summary>
/// Parses a single line of FFmpeg stdout/stderr for the `time=`/`frame=`/`fps=`/`speed=`
/// progress fields FFmpeg prints while encoding.
/// </summary>
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*(?<frame>\d+).*?fps=\s*(?<fps>\d+(?:\.\d+)?).*?speed=\s*(?<speed>\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
private static partial Regex FrameRegex();
}
+7 -64
View File
@@ -1,15 +1,11 @@
using System.Diagnostics; using System.Diagnostics;
using System.Globalization;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.IO; using System.IO;
namespace AmiReel.Services; 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<string> RunAsync( public async Task<string> RunAsync(
string executable, string executable,
IEnumerable<string> arguments, IEnumerable<string> arguments,
@@ -17,7 +13,7 @@ public sealed partial class ProcessRunner
Action<TimeSpan>? position, Action<TimeSpan>? position,
CancellationToken token, CancellationToken token,
bool allowFailure = false, bool allowFailure = false,
Action<ProcessOutput>? outputHandler = null) Action<FfmpegOutputLine>? outputHandler = null)
{ {
ProcessStartInfo start = new() ProcessStartInfo start = new()
{ {
@@ -51,12 +47,12 @@ public sealed partial class ProcessRunner
private static async Task PumpAsync( private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action<string>? log, StreamReader reader, StringBuilder output, Action<string>? log,
Action<TimeSpan>? position, CancellationToken token, Action<ProcessOutput>? outputHandler) Action<TimeSpan>? position, CancellationToken token, Action<FfmpegOutputLine>? outputHandler)
{ {
while (await reader.ReadLineAsync(token) is { } line) while (await reader.ReadLineAsync(token) is { } line)
{ {
output.AppendLine(line); output.AppendLine(line);
ProcessOutput parsed = ParseOutput(line); FfmpegOutputLine parsed = FfmpegProgressParser.Parse(line);
if (!IsNoise(parsed)) if (!IsNoise(parsed))
log?.Invoke(line); log?.Invoke(line);
if (parsed.Time is { } time) if (parsed.Time is { } time)
@@ -65,32 +61,7 @@ public sealed partial class ProcessRunner
} }
} }
private static ProcessOutput ParseOutput(string line) internal static bool IsNoise(FfmpegOutputLine output)
{
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)
{ {
string line = output.Line.TrimStart(); string line = output.Line.TrimStart();
if (string.IsNullOrWhiteSpace(line)) if (string.IsNullOrWhiteSpace(line))
@@ -111,39 +82,11 @@ public sealed partial class ProcessRunner
|| line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase)) || line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase))
return false; return false;
return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase) return FfmpegOutputFilter.IsBoilerplateLine(line)
|| 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)
|| line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("title :", StringComparison.OrdinalIgnoreCase) || line.StartsWith("title :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("CPB properties:", StringComparison.OrdinalIgnoreCase) || line.StartsWith("CPB properties:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[", 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);
} }
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
[GeneratedRegex(@"frame=\s*(?<frame>\d+).*?fps=\s*(?<fps>\d+(?:\.\d+)?).*?speed=\s*(?<speed>\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
private static partial Regex FrameRegex();
} }
+2 -2
View File
@@ -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); TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"]; List<string> 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.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."); if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
+3 -25
View File
@@ -39,38 +39,16 @@ public static class UserFacingErrors
return string.Join(Environment.NewLine, summary); 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) return FfmpegOutputFilter.IsBoilerplateLine(line)
|| 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)
|| line.StartsWith("major_brand", StringComparison.OrdinalIgnoreCase) || line.StartsWith("major_brand", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("minor_version", StringComparison.OrdinalIgnoreCase) || line.StartsWith("minor_version", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("compatible_brands", StringComparison.OrdinalIgnoreCase) || line.StartsWith("compatible_brands", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("encoder", 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("[in#", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("[out#", StringComparison.OrdinalIgnoreCase) || line.StartsWith("[out#", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Last message repeated", StringComparison.OrdinalIgnoreCase) || line.StartsWith("Last message repeated", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("frame=", 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);
} }
} }