Add video preview, source crop, find-videos, and render toasts
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.
This commit is contained in:
@@ -81,6 +81,55 @@ public class AppSettingsTests
|
||||
Assert.AreEqual("3", normalized.FadeSeconds);
|
||||
Assert.AreEqual("4", normalized.EndCardHoldSeconds);
|
||||
Assert.AreEqual("10", normalized.ThumbnailInterval);
|
||||
Assert.AreEqual("", normalized.SourceSearchFolder);
|
||||
Assert.AreEqual("Auto", normalized.VideoSourceCropMode);
|
||||
Assert.AreEqual("0", normalized.VideoCropLeft);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Normalize_WithBlankVideoSourceSettings_RestoresDefaults()
|
||||
{
|
||||
// Arrange
|
||||
AppSettings settings = AppSettings.Default(@"C:\Videos") with
|
||||
{
|
||||
SourceSearchFolder = null!,
|
||||
VideoSourceCropMode = "",
|
||||
VideoCropLeft = " ",
|
||||
VideoCropRight = "",
|
||||
VideoCropTop = "",
|
||||
VideoCropBottom = "",
|
||||
};
|
||||
|
||||
// Act
|
||||
AppSettings normalized = settings.Normalize(@"C:\Videos");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("", normalized.SourceSearchFolder);
|
||||
Assert.AreEqual("Auto", normalized.VideoSourceCropMode);
|
||||
Assert.AreEqual("0", normalized.VideoCropLeft);
|
||||
Assert.AreEqual("0", normalized.VideoCropRight);
|
||||
Assert.AreEqual("0", normalized.VideoCropTop);
|
||||
Assert.AreEqual("0", normalized.VideoCropBottom);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Normalize_WithSourceSearchFolder_KeepsItUnchanged()
|
||||
{
|
||||
// Arrange
|
||||
AppSettings settings = AppSettings.Default(@"C:\Videos") with
|
||||
{
|
||||
SourceSearchFolder = @"D:\AmigaIntrosVideos",
|
||||
VideoSourceCropMode = "Center",
|
||||
VideoCropLeft = "12",
|
||||
};
|
||||
|
||||
// Act
|
||||
AppSettings normalized = settings.Normalize(@"C:\Videos");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(@"D:\AmigaIntrosVideos", normalized.SourceSearchFolder);
|
||||
Assert.AreEqual("Center", normalized.VideoSourceCropMode);
|
||||
Assert.AreEqual("12", normalized.VideoCropLeft);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -16,6 +16,8 @@ public class FfmpegOutputFilterTests
|
||||
[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%")]
|
||||
[DataRow("Last message repeated 1 times")]
|
||||
[DataRow("Last message repeated 24 times")]
|
||||
public void IsBoilerplateLine_WithKnownFfmpegBanner_ReturnsTrue(string line)
|
||||
{
|
||||
// Act
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class ProcessRunnerTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void IsNoise_WithLastMessageRepeated_ReturnsTrue()
|
||||
{
|
||||
// Arrange
|
||||
FfmpegOutputLine line = FfmpegProgressParser.Parse("Last message repeated 1 times");
|
||||
|
||||
// Act
|
||||
bool result = ProcessRunner.IsNoise(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsTrue(result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IsNoise_WithCropdetectSpec_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
FfmpegOutputLine line = FfmpegProgressParser.Parse("crop=672:ih:56:0");
|
||||
|
||||
// Act
|
||||
bool result = ProcessRunner.IsNoise(line);
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class RenderNotifierTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void DescribeCompleted_ForVideo_UsesRenderCompleteCopy()
|
||||
{
|
||||
// Act
|
||||
(string title, string body) = RenderNotifier.DescribeCompleted(shorts: false);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Render complete", title);
|
||||
StringAssert.Contains(body, "successfully");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DescribeCompleted_ForShorts_UsesShortCompleteCopy()
|
||||
{
|
||||
// Act
|
||||
(string title, string body) = RenderNotifier.DescribeCompleted(shorts: true);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Short complete", title);
|
||||
StringAssert.Contains(body, "Short");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DescribeFailed_WithBlankSummary_UsesFallbackBody()
|
||||
{
|
||||
// Act
|
||||
(string title, string body) = RenderNotifier.DescribeFailed(" ");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Render failed", title);
|
||||
Assert.AreEqual("The render failed.", body);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DescribeFailed_WithLongSummary_TruncatesBody()
|
||||
{
|
||||
// Arrange
|
||||
string summary = new('x', 250);
|
||||
|
||||
// Act
|
||||
(string title, string body) = RenderNotifier.DescribeFailed(summary);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Render failed", title);
|
||||
Assert.AreEqual(180, body.Length);
|
||||
StringAssert.EndsWith(body, "...");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DescribeCancelled_ReturnsCancelledCopy()
|
||||
{
|
||||
// Act
|
||||
(string title, string body) = RenderNotifier.DescribeCancelled();
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("Render cancelled", title);
|
||||
StringAssert.Contains(body, "cancelled");
|
||||
}
|
||||
}
|
||||
@@ -127,6 +127,154 @@ public class RenderPipelineTests
|
||||
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()
|
||||
{
|
||||
|
||||
@@ -380,6 +380,53 @@ public class ShortsPipelineTests
|
||||
Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SourceCropFiltersResolve_WithManualInsets_IgnoresAutoDetection()
|
||||
{
|
||||
// Act
|
||||
string filter = SourceCropFilters.Resolve(ShortSourceCropMode.Auto, 8, 4, 2, 1, "crop=800:ih:40:0");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("crop=iw-8-4:ih-2-1:8:2", filter);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SourceCropFiltersResolve_WithNoneMode_ReturnsEmptyFilter()
|
||||
{
|
||||
// Act
|
||||
string filter = SourceCropFilters.Resolve(ShortSourceCropMode.None, 0, 0, 0, 0, "crop=800:ih:40:0");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("", filter);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void BuildVideoBaseFilter_WithNoCrop_ScalesTheFullSource()
|
||||
{
|
||||
// Act
|
||||
string filter = ShortsPipeline.BuildVideoBaseFilter("", "scale=1000:-2:flags=lanczos", blur: 0, opacity: 1);
|
||||
|
||||
// Assert
|
||||
StringAssert.Contains(filter, "[0:v]scale=1000:-2:flags=lanczos,setsar=1[fg]");
|
||||
Assert.IsFalse(filter.Contains("[0:v]crop=", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TryBuildAutoHorizontalFilter_UsesMostCommonSpecAndEvenInset()
|
||||
{
|
||||
// Arrange
|
||||
const string output =
|
||||
"crop=800:480:100:0\n" +
|
||||
"crop=800:480:100:0\n" +
|
||||
"crop=760:480:140:0";
|
||||
|
||||
// Act
|
||||
string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual("crop=792:ih:104:0", filter);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SourceTargetWidth_SubtractsBothMargins()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using AmiReel.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace AmiReel.Tests.Services;
|
||||
|
||||
[TestClass]
|
||||
public class SourceRecordingFinderTests
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindSequentialOutputs_WithMissingFolder_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(
|
||||
Path.Combine(Path.GetTempPath(), "AmiReelTests", "missing-" + Guid.NewGuid().ToString("N")));
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindSequentialOutputs_WithBlankFolder_ReturnsEmpty()
|
||||
{
|
||||
// Act
|
||||
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(" ");
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindSequentialOutputs_WithOnlyOutputAvi_ReturnsSingleFile()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
string first = Path.Combine(directory, "output.avi");
|
||||
File.WriteAllText(first, "one");
|
||||
|
||||
// Act
|
||||
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(directory);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(1, result.Count);
|
||||
Assert.AreEqual(first, result[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindSequentialOutputs_WithNumberedSequence_ReturnsFilesUntilFirstGap()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
File.WriteAllText(Path.Combine(directory, "output.avi"), "0");
|
||||
File.WriteAllText(Path.Combine(directory, "output_1.avi"), "1");
|
||||
File.WriteAllText(Path.Combine(directory, "output_2.avi"), "2");
|
||||
File.WriteAllText(Path.Combine(directory, "output_4.avi"), "skipped");
|
||||
File.WriteAllText(Path.Combine(directory, "output.mp4"), "wrong-ext");
|
||||
|
||||
// Act
|
||||
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(directory);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(3, result.Count);
|
||||
Assert.AreEqual("output.avi", Path.GetFileName(result[0]));
|
||||
Assert.AreEqual("output_1.avi", Path.GetFileName(result[1]));
|
||||
Assert.AreEqual("output_2.avi", Path.GetFileName(result[2]));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindSequentialOutputs_WithoutBaseOutputAvi_ReturnsEmptyEvenIfNumberedExists()
|
||||
{
|
||||
// Arrange
|
||||
string directory = CreateTempDirectory();
|
||||
File.WriteAllText(Path.Combine(directory, "output_1.avi"), "1");
|
||||
|
||||
// Act
|
||||
IReadOnlyList<string> result = SourceRecordingFinder.FindSequentialOutputs(directory);
|
||||
|
||||
// Assert
|
||||
Assert.AreEqual(0, result.Count);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user