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:
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,3 +1,4 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Project Path="AmiReel.csproj" />
|
<Project Path="AmiReel.csproj" />
|
||||||
|
<Project Path="AmiReel.Tests/AmiReel.Tests.csproj" />
|
||||||
</Solution>
|
</Solution>
|
||||||
|
|||||||
+30
-26
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
|
||||||
|
[assembly: InternalsVisibleTo("AmiReel.Tests")]
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user