Bring the Video tab closer to Shorts with 4K preview stills and crop modes (none, auto, center, plus the visual editor). Settings store a default source folder so Find videos can collect output.avi sequences. Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a Windows notification when a video or short finishes or fails.
365 lines
12 KiB
C#
365 lines
12 KiB
C#
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 ValidatePreview_WithMissingOutputName_DoesNotThrow()
|
|
{
|
|
// Arrange
|
|
string directory = CreateTempDirectory();
|
|
string input = Path.Combine(directory, "clip.avi");
|
|
File.WriteAllText(input, "data");
|
|
RenderSettings settings = ValidSettings([input], "") with { OutputName = "" };
|
|
|
|
// Act & Assert (no exception)
|
|
RenderPipeline.ValidatePreview(settings);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void ValidatePreview_WithNoInputFiles_ThrowsArgumentException()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings([], @"C:\out");
|
|
|
|
// Act & Assert
|
|
Assert.ThrowsExactly<ArgumentException>(() => RenderPipeline.ValidatePreview(settings));
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildJoinVideoFilter_IncludesCropThenScaleAndPad()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out");
|
|
|
|
// Act
|
|
string filter = RenderPipeline.BuildJoinVideoFilter(settings, "crop=iw-10-10:ih-4-4:10:4");
|
|
|
|
// Assert
|
|
StringAssert.StartsWith(filter, "crop=iw-10-10:ih-4-4:10:4,scale=2880:2160");
|
|
StringAssert.Contains(filter, "pad=3840:2160");
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildSourceCropFilter_WithManualInsets_UsesEdgeCrop()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
|
|
{
|
|
CropLeft = 20,
|
|
CropRight = 10,
|
|
CropTop = 4,
|
|
CropBottom = 6,
|
|
};
|
|
|
|
// Act
|
|
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
|
|
|
|
// Assert
|
|
Assert.AreEqual("crop=iw-20-10:ih-4-6:20:4", filter);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildSourceCropFilter_WithAutoDetection_UsesDetectedFilter()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
|
|
{
|
|
SourceCropMode = ShortSourceCropMode.Auto,
|
|
};
|
|
|
|
// Act
|
|
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
|
|
|
|
// Assert
|
|
Assert.AreEqual("crop=800:ih:40:0", filter);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildSourceCropFilter_WithCenterMode_UsesFourByThreeCrop()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
|
|
{
|
|
SourceCropMode = ShortSourceCropMode.Center,
|
|
};
|
|
|
|
// Act
|
|
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
|
|
|
|
// Assert
|
|
Assert.AreEqual(ShortsPipeline.DefaultSourceCropFilter, filter);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildSourceCropFilter_WithNoneMode_AppliesNoCrop()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out") with
|
|
{
|
|
SourceCropMode = ShortSourceCropMode.None,
|
|
};
|
|
|
|
// Act
|
|
string filter = RenderPipeline.BuildSourceCropFilter(settings, "crop=800:ih:40:0");
|
|
|
|
// Assert
|
|
Assert.AreEqual("", filter);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void BuildJoinVideoFilter_WithNoCrop_StartsAtScale()
|
|
{
|
|
// Arrange
|
|
RenderSettings settings = ValidSettings(["clip.avi"], @"C:\out");
|
|
|
|
// Act
|
|
string filter = RenderPipeline.BuildJoinVideoFilter(settings, "");
|
|
|
|
// Assert
|
|
StringAssert.StartsWith(filter, "scale=2880:2160");
|
|
Assert.IsFalse(filter.Contains("crop=", StringComparison.Ordinal));
|
|
}
|
|
|
|
[TestMethod]
|
|
public void PreviewSeekSeconds_WithDurationAfterTrim_StaysInsideRemainingClip()
|
|
{
|
|
// Act
|
|
double seek = RenderPipeline.PreviewSeekSeconds(4, 30);
|
|
|
|
// Assert
|
|
Assert.AreEqual(5.2, seek, 0.001);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void PreviewSeekSeconds_WhenTrimIsPastDuration_ReturnsTrimStart()
|
|
{
|
|
// Act
|
|
double seek = RenderPipeline.PreviewSeekSeconds(40, 10);
|
|
|
|
// Assert
|
|
Assert.AreEqual(40, seek, 0.001);
|
|
}
|
|
|
|
[TestMethod]
|
|
public void FormatTimestamp_FormatsInvariantHoursMinutesSeconds()
|
|
{
|
|
// Act
|
|
string stamp = RenderPipeline.FormatTimestamp(4.414);
|
|
|
|
// Assert
|
|
Assert.AreEqual("00:00:04.414", stamp);
|
|
}
|
|
|
|
[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)));
|
|
}
|
|
}
|