Compare commits

...

4 Commits

Author SHA1 Message Date
klevze fc02cd9602 Update README project layout for tests and new Services files
Reflect the current repo structure: AmiReel.Tests/, Properties/AssemblyInfo.cs,
and the FfmpegProgressParser/FfmpegOutputFilter split out of ProcessRunner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:27:15 +02:00
klevze 8f27aee11a Fix title bar icon missing in single-file published builds
The title bar Image used Source="ms-appx:///Assets/Square44x44Logo.scale-200.png".
That resolves fine in a normal build (Assets/ and AmiReel.pri sit next to the exe),
but a single-file self-contained publish (IncludeAllContentForSelfExtract=true)
bundles Content items into the exe in a way ms-appx:// can no longer resolve at
runtime, leaving the title bar icon blank — reproduced with the actual
publish-win-x64.ps1 output, confirmed fixed the same way.

Load the icon from a plain embedded resource instead (same mechanism already
used for ffmpeg.exe/ffprobe.exe): the PNG is embedded via
<EmbeddedResource LogicalName="AmiReel.Assets.TitleBarIcon.png">, and
MainWindow loads it at startup via GetManifestResourceStream + BitmapImage.
SetSourceAsync. This bypasses the ms-appx/MRT resource pipeline entirely, so
it behaves identically in dotnet build, dotnet run, and a single-file publish.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 16:01:20 +02:00
klevze c412469773 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>
2026-08-13 14:27:57 +02:00
klevze 3de3357d23 Code review cleanup: dead code, unused capability, accessibility gap
- Remove unused `using Microsoft.Win32;` from MainPage.xaml.cs (leftover
  from the WPF OpenFileDialog era; WinUI uses Windows.Storage.Pickers)
- Trim App.xaml.cs down to the two usings it actually needs instead of the
  full WinUI3 template boilerplate list
- Change App.MainWindowInstance to an internal setter (only MainWindow
  itself should assign it)
- Add AutomationProperties.Name to the icon-only Settings button so screen
  readers announce it (it only had a mouse tooltip before)
- Delete Assets/AppIcon.ico: unreferenced by both the manifest and code,
  the real app icon comes from ApplicationIcon (branding/AmiReel.ico) and
  AppWindow.SetIcon at runtime
- Drop the systemAIModels capability from Package.appxmanifest; the app
  doesn't call any Windows AI API, so declaring it is an unnecessary
  privilege request

Verified: dotnet build produces 0 warnings/errors, app launches unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:13:22 +02:00
25 changed files with 932 additions and 198 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);
}
}
+14 -1
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" />
@@ -27,7 +36,6 @@
<Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" /> <Content Include="Assets\Square44x44Logo.targetsize-24_altform-unplated.png" />
<Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" /> <Content Include="Assets\Square44x44Logo.targetsize-48_altform-lightunplated.png" />
<Content Include="Assets\StoreLogo.png" /> <Content Include="Assets\StoreLogo.png" />
<Content Include="Assets\AppIcon.ico" />
<Content Include="Assets\Wide310x150Logo.scale-200.png" /> <Content Include="Assets\Wide310x150Logo.scale-200.png" />
</ItemGroup> </ItemGroup>
@@ -38,6 +46,11 @@
<ItemGroup> <ItemGroup>
<EmbeddedResource Include="ThirdParty\ffmpeg.exe" Condition="Exists('ThirdParty\ffmpeg.exe')" LogicalName="AmiReel.Tools.ffmpeg.exe" /> <EmbeddedResource Include="ThirdParty\ffmpeg.exe" Condition="Exists('ThirdParty\ffmpeg.exe')" LogicalName="AmiReel.Tools.ffmpeg.exe" />
<EmbeddedResource Include="ThirdParty\ffprobe.exe" Condition="Exists('ThirdParty\ffprobe.exe')" LogicalName="AmiReel.Tools.ffprobe.exe" /> <EmbeddedResource Include="ThirdParty\ffprobe.exe" Condition="Exists('ThirdParty\ffprobe.exe')" LogicalName="AmiReel.Tools.ffprobe.exe" />
<!-- Loaded at runtime via GetManifestResourceStream for the title bar icon (MainWindow.xaml.cs).
The Assets\*.png Content items above go through the ms-appx:// / MRT resource pipeline,
which a single-file self-contained publish bundles into the exe in a way that ms-appx://
can no longer resolve at runtime — this embedded copy sidesteps that entirely. -->
<EmbeddedResource Include="Assets\Square44x44Logo.scale-200.png" LogicalName="AmiReel.Assets.TitleBarIcon.png" />
</ItemGroup> </ItemGroup>
<!-- <!--
+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>
+2 -16
View File
@@ -1,20 +1,6 @@
using Windows.ApplicationModel; using Microsoft.UI.Xaml;
using Windows.ApplicationModel.Activation;
using Windows.Foundation;
using Windows.Foundation.Collections;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Controls.Primitives;
using Microsoft.UI.Xaml.Data;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
using Microsoft.UI.Xaml.Shapes;
using WinRT.Interop; using WinRT.Interop;
// To learn more about WinUI, the WinUI project structure,
// and more about our project templates, see: http://aka.ms/winui-project-info.
namespace AmiReel; namespace AmiReel;
/// <summary> /// <summary>
@@ -24,7 +10,7 @@ public partial class App : Application
{ {
private Window? _window; private Window? _window;
public static IntPtr MainWindowHandle { get; private set; } public static IntPtr MainWindowHandle { get; private set; }
public static MainWindow? MainWindowInstance { get; set; } public static MainWindow? MainWindowInstance { get; internal set; }
/// <summary> /// <summary>
/// Initializes the singleton application object. This is the first line of authored code /// Initializes the singleton application object. This is the first line of authored code
Binary file not shown.

Before

Width:  |  Height:  |  Size: 200 KiB

+2 -1
View File
@@ -113,7 +113,8 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10">
<Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets" <Button x:Name="OpenSettingsButton" Content="&#xE713;" FontFamily="Segoe Fluent Icons, Segoe MDL2 Assets"
Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}" ToolTipService.ToolTip="Settings" /> Click="OpenSettings_Click" Style="{StaticResource IconButtonStyle}"
ToolTipService.ToolTip="Settings" AutomationProperties.Name="Settings" />
<Button x:Name="CancelButton" Content="Cancel" Click="Cancel_Click" IsEnabled="False" /> <Button x:Name="CancelButton" Content="Cancel" Click="Cancel_Click" IsEnabled="False" />
<Button x:Name="RenderButton" Content="Start render" Click="Render_Click" Style="{StaticResource PrimaryButtonStyle}" /> <Button x:Name="RenderButton" Content="Start render" Click="Render_Click" Style="{StaticResource PrimaryButtonStyle}" />
</StackPanel> </StackPanel>
+30 -27
View File
@@ -6,7 +6,6 @@ using AmiReel.Models;
using AmiReel.Services; using AmiReel.Services;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.Win32;
using Windows.ApplicationModel.DataTransfer; using Windows.ApplicationModel.DataTransfer;
using Windows.Storage; using Windows.Storage;
using Windows.Storage.Pickers; using Windows.Storage.Pickers;
@@ -223,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)
@@ -326,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";
+1 -1
View File
@@ -16,7 +16,7 @@
<Grid x:Name="AppTitleBar" Grid.Row="0" Height="44" Background="{ThemeResource PanelBrush}"> <Grid x:Name="AppTitleBar" Grid.Row="0" Height="44" Background="{ThemeResource PanelBrush}">
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="14,0,0,0"> <StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center" Margin="14,0,0,0">
<Image Source="ms-appx:///Assets/Square44x44Logo.scale-200.png" <Image x:Name="TitleBarIcon"
Width="20" Width="20"
Height="20" Height="20"
Stretch="Uniform" /> Stretch="Uniform" />
+28
View File
@@ -2,9 +2,12 @@ using Microsoft.UI;
using Microsoft.UI.Windowing; using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml; using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media.Imaging;
using System; using System;
using System.IO; using System.IO;
using System.Reflection;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Graphics; using Windows.Graphics;
using Windows.UI; using Windows.UI;
using WinRT.Interop; using WinRT.Interop;
@@ -36,6 +39,7 @@ public sealed partial class MainWindow : Window
AppWindow.SetIcon(executablePath); AppWindow.SetIcon(executablePath);
SetDefaultSizeAndPosition(); SetDefaultSizeAndPosition();
LoadTitleBarIcon();
// Navigate the root frame to the main page on startup. // Navigate the root frame to the main page on startup.
RootFrame.Navigate(typeof(MainPage)); RootFrame.Navigate(typeof(MainPage));
@@ -43,6 +47,30 @@ public sealed partial class MainWindow : Window
AppWindow.Closing += AppWindow_Closing; AppWindow.Closing += AppWindow_Closing;
} }
/// <summary>
/// Loads the title bar icon from an embedded resource instead of an "ms-appx:///Assets/..."
/// URI. A single-file self-contained publish bundles Content/Assets items into the exe in a
/// way the ms-appx resource pipeline can no longer resolve at runtime, leaving the title bar
/// blank; a plain embedded resource stream works the same in every publish mode.
/// </summary>
private async void LoadTitleBarIcon()
{
try
{
Assembly assembly = Assembly.GetExecutingAssembly();
using Stream? stream = assembly.GetManifestResourceStream("AmiReel.Assets.TitleBarIcon.png");
if (stream is null) return;
BitmapImage bitmap = new();
await bitmap.SetSourceAsync(stream.AsRandomAccessStream());
TitleBarIcon.Source = bitmap;
}
catch
{
// Non-critical: the title bar simply shows no icon if this fails.
}
}
private void SetDefaultSizeAndPosition() private void SetDefaultSizeAndPosition()
{ {
const int DefaultWidth = 1280; const int DefaultWidth = 1280;
+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);
+1 -3
View File
@@ -5,8 +5,7 @@
xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities" xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:systemai="http://schemas.microsoft.com/appx/manifest/systemai/windows10" IgnorableNamespaces="uap rescap">
IgnorableNamespaces="uap rescap systemai">
<Identity <Identity
Name="F004AE73-5989-46F3-B913-E9A3A355713F" Name="F004AE73-5989-46F3-B913-E9A3A355713F"
@@ -48,6 +47,5 @@
<Capabilities> <Capabilities>
<rescap:Capability Name="runFullTrust" /> <rescap:Capability Name="runFullTrust" />
<systemai:Capability Name="systemAIModels"/>
</Capabilities> </Capabilities>
</Package> </Package>
+3
View File
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("AmiReel.Tests")]
+24
View File
@@ -43,11 +43,18 @@ Models/
Services/ Services/
RenderPipeline.cs Main render workflow (FFmpeg orchestration) RenderPipeline.cs Main render workflow (FFmpeg orchestration)
ProcessRunner.cs FFmpeg process execution and progress parsing ProcessRunner.cs FFmpeg process execution and progress parsing
FfmpegProgressParser.cs Parses time=/frame=/fps=/speed= from FFmpeg output lines
FfmpegOutputFilter.cs Shared FFmpeg banner/boilerplate line filtering
ToolExtractor.cs FFmpeg resolution, extraction, and fallback logic ToolExtractor.cs FFmpeg resolution, extraction, and fallback logic
MediaProbe.cs Duration/audio-stream probing via ffprobe MediaProbe.cs Duration/audio-stream probing via ffprobe
FfmpegLibraryProbe.cs NVENC capability probing FfmpegLibraryProbe.cs NVENC capability probing
AppSettingsStore.cs Settings load/save (JSON in %LOCALAPPDATA%) AppSettingsStore.cs Settings load/save (JSON in %LOCALAPPDATA%)
UserFacingErrors.cs Exception-to-message translation UserFacingErrors.cs Exception-to-message translation
Properties/
AssemblyInfo.cs InternalsVisibleTo AmiReel.Tests
launchSettings.json
PublishProfiles/ win-x64/x86/arm64 publish profiles
AmiReel.Tests/ Unit tests (MSTest), mirrors Models/ and Services/
ThirdParty/ Embedded ffmpeg.exe / ffprobe.exe (not checked in, see below) ThirdParty/ Embedded ffmpeg.exe / ffprobe.exe (not checked in, see below)
publish-win-x64.ps1 Publish script publish-win-x64.ps1 Publish script
``` ```
@@ -74,6 +81,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 +161,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);
} }
} }