Files
AmiReel/AmiReel.Tests/Services/ShortsPipelineTests.cs
T
klevze e635b708a2 Improve crop editor and copy publish output to dist
Add resize handles, zoom, view brightness, and a multi-frame still
strip. Keep the crop box on mouse-up, and copy AmiReel.exe to dist.
2026-08-15 22:59:20 +02:00

550 lines
18 KiB
C#

using AmiReel.Models;
using AmiReel.Services;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AmiReel.Tests.Services;
[TestClass]
public class ShortsPipelineTests
{
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 ShortSettings ValidSettings(string input, string font, ShortStyle style = ShortStyle.Crop, string background = "") => new()
{
InputFile = input,
OutputPath = Path.Combine(Path.GetDirectoryName(input) ?? "", "out.mp4"),
Title = "Test Short",
Hook = "THIS RAN ON AN AMIGA",
Website = "AMIGADB.NET",
Start = "00:00:00",
DurationSeconds = 30,
Style = style,
BackgroundImage = background,
FontFile = font,
FfmpegPath = "",
FfprobePath = "",
};
[TestMethod]
public void EscapeFfmpegPath_WithBackslashesColonsAndQuotes_EscapesAllThree()
{
// Act
string result = ShortsPipeline.EscapeFfmpegPath(@"C:\Fonts\font's.ttf");
// Assert
Assert.AreEqual(@"C\:\\Fonts\\font\'s.ttf", result);
}
[TestMethod]
public void EscapeFfmpegPath_EscapesBackslashesBeforeIntroducingNewOnes()
{
// Act: if colon/quote escaping ran before backslash escaping, the backslashes
// they introduce would themselves get doubled, which must not happen.
string result = ShortsPipeline.EscapeFfmpegPath("C:\\a");
// Assert
Assert.AreEqual("C\\:\\\\a", result);
}
[TestMethod]
public void BuildMeta_WithAllFieldsPopulated_JoinsWithMiddleDot()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "1991", Type = "Cracktro" };
// Act
string meta = ShortsPipeline.BuildMeta(settings);
// Assert
Assert.AreEqual("Pirates · 1991 · Cracktro", meta);
}
[TestMethod]
public void BuildMeta_WithSomeFieldsBlank_SkipsBlankFields()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf") with { Group = "Pirates", Year = "", Type = " " };
// Act
string meta = ShortsPipeline.BuildMeta(settings);
// Assert
Assert.AreEqual("Pirates", meta);
}
[TestMethod]
public void BuildMeta_WithAllFieldsBlank_ReturnsEmptyString()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf");
// Act
string meta = ShortsPipeline.BuildMeta(settings);
// Assert
Assert.AreEqual("", meta);
}
[TestMethod]
public void Validate_WithMissingInputFile_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithBlankTitle_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font) with { Title = "" };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithZeroDuration_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font) with { DurationSeconds = 0 };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_WithMissingFontFile_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf"));
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_BrandStyleWithoutBackgroundImage_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Brand);
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_NonBrandStyleWithoutBackgroundImage_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Crop);
// Act & Assert (no exception)
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void Validate_VideoStyleWithoutBackgroundVideo_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video);
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_VideoStyleWithBackgroundVideo_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
string background = Path.Combine(directory, "bg.mp4");
File.WriteAllText(background, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with { BackgroundVideo = background };
// Act & Assert (no exception)
ShortsPipeline.Validate(settings);
}
[TestMethod]
public void Validate_VideoOpacityAboveOne_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
string background = Path.Combine(directory, "bg.mp4");
File.WriteAllText(background, "data");
ShortSettings settings = ValidSettings(input, font, ShortStyle.Video) with
{
BackgroundVideo = background,
VideoOpacity = 1.2,
};
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void Validate_SourceMarginTooWide_ThrowsArgumentException()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
string font = Path.Combine(directory, "font.ttf");
File.WriteAllText(font, "data");
ShortSettings settings = ValidSettings(input, font) with { SourceMarginX = 540 };
// Act & Assert
Assert.ThrowsExactly<ArgumentException>(() => ShortsPipeline.Validate(settings));
}
[TestMethod]
public void GetBaseFilter_ForEveryStyle_ReturnsAFilterEndingInBaseTag()
{
foreach (ShortStyle style in Enum.GetValues<ShortStyle>())
{
// Act
string filter = ShortsPipeline.GetBaseFilter(style);
// Assert
Assert.IsFalse(string.IsNullOrWhiteSpace(filter), $"{style} produced an empty filter.");
StringAssert.Contains(filter, "[base]", $"{style} filter does not label an output as [base].");
}
}
[TestMethod]
public void BuildFilterComplex_IncludesAllFourTextOverlaysAndTheirTimingGates()
{
// Act
string result = ShortsPipeline.BuildFilterComplex(
"[0:v]copy[base]",
fontEsc: "C\\:\\\\font.ttf",
hookEsc: "hook.txt", titleEsc: "title.txt", metaEsc: "meta.txt", websiteEsc: "website.txt",
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5);
// Assert
StringAssert.Contains(result, "textfile='hook.txt'");
StringAssert.Contains(result, "textfile='title.txt'");
StringAssert.Contains(result, "textfile='meta.txt'");
StringAssert.Contains(result, "textfile='website.txt'");
StringAssert.Contains(result, "enable='between(t,0,2.7)'");
StringAssert.Contains(result, "enable='gte(t,23)'");
StringAssert.Contains(result, "enable='gte(t,26.5)'");
StringAssert.Contains(result, "[vout]");
}
[TestMethod]
public void GetBaseFilter_VideoStyle_UsesBackgroundVideoInput()
{
// Act
string filter = ShortsPipeline.GetBaseFilter(ShortStyle.Video);
// Assert
StringAssert.Contains(filter, "[1:v]");
StringAssert.Contains(filter, "colorchannelmixer=aa=1");
StringAssert.Contains(filter, "[base]");
Assert.IsFalse(filter.Contains("gblur=", StringComparison.Ordinal));
}
[TestMethod]
public void BuildVideoBaseFilter_WithBlur_InsertsGblur()
{
// Act
string filter = ShortsPipeline.BuildVideoBaseFilter(
"crop=iw:ih:0:0", "scale=1000:-2:flags=lanczos", blur: 8, opacity: 0.65);
// Assert
StringAssert.Contains(filter, "gblur=sigma=8");
StringAssert.Contains(filter, "colorchannelmixer=aa=0.65");
StringAssert.Contains(filter, "crop=iw:ih:0:0,scale=1000:-2:flags=lanczos");
}
[TestMethod]
public void ApplySourceFade_WhenFadeIsZero_ReturnsOriginalFilter()
{
// Arrange
const string baseFilter = "[0:v]split=2[bg][fg]";
// Act
string result = ShortsPipeline.ApplySourceFade(baseFilter, 0);
// Assert
Assert.AreEqual(baseFilter, result);
}
[TestMethod]
public void ApplySourceFade_WhenFadeIsPositive_RewritesSourceLabel()
{
// Act
string result = ShortsPipeline.ApplySourceFade("[0:v]split=2[bg][fg];[0:a]anull[a]", 1.5);
// Assert
StringAssert.StartsWith(result, "[0:v]format=rgba,fade=t=in:st=0:d=1.5:alpha=1[srcfade];");
StringAssert.Contains(result, "[srcfade]split=2[bg][fg]");
StringAssert.Contains(result, "[0:a]anull[a]");
Assert.IsFalse(result.Contains("[0:v]split", StringComparison.Ordinal));
}
[TestMethod]
public void SelectMostCommonCropSpec_PicksTheMostFrequentSpec()
{
// Arrange
const string output =
"crop=800:480:100:0\n" +
"crop=780:480:120:0\n" +
"crop=780:480:120:0\n" +
"crop=760:480:140:0";
// Act
string? spec = ShortsPipeline.SelectMostCommonCropSpec(output);
// Assert
Assert.AreEqual("crop=780:480:120:0", spec);
}
[TestMethod]
public void TryBuildAutoHorizontalCrop_AppliesEvenSafetyInset()
{
// Act
bool ok = ShortsPipeline.TryBuildAutoHorizontalCrop(800, 100, out int width, out int x);
// Assert
Assert.IsTrue(ok);
Assert.AreEqual(792, width);
Assert.AreEqual(104, x);
}
[TestMethod]
public void BuildManualCropFilter_UsesEdgeInsets()
{
// Act
string filter = ShortsPipeline.BuildManualCropFilter(120, 80, 10, 20);
// Assert
Assert.AreEqual("crop=iw-120-80:ih-10-20:120:10", filter);
}
[TestMethod]
public void SourceTargetWidth_SubtractsBothMargins()
{
// Act
int width = ShortsPipeline.SourceTargetWidth(40);
// Assert
Assert.AreEqual(1000, width);
}
[TestMethod]
public void ValidatePreview_WithMissingTitleAndOutput_DoesNotThrow()
{
// Arrange
string directory = CreateTempDirectory();
string input = Path.Combine(directory, "clip.mp4");
File.WriteAllText(input, "data");
ShortSettings settings = ValidSettings(input, Path.Combine(directory, "missing-font.ttf")) with
{
Title = "",
OutputPath = "",
};
// Act & Assert (no exception)
ShortsPipeline.ValidatePreview(settings);
}
[TestMethod]
public void ValidatePreview_WithMissingInput_ThrowsFileNotFoundException()
{
// Arrange
string directory = CreateTempDirectory();
ShortSettings settings = ValidSettings(Path.Combine(directory, "missing.mp4"), "font.ttf");
// Act & Assert
Assert.ThrowsExactly<FileNotFoundException>(() => ShortsPipeline.ValidatePreview(settings));
}
[TestMethod]
public void PreviewOverlayWindows_Opening_HidesTitleCard()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Opening, 30);
// Assert
Assert.AreEqual(30, hookEnd);
Assert.AreEqual(31, infoStart);
Assert.AreEqual(31, ctaStart);
}
[TestMethod]
public void PreviewOverlayWindows_Closing_ShowsTitlesImmediately()
{
// Act
(double hookEnd, double infoStart, double ctaStart) = ShortsPipeline.PreviewOverlayWindows(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(-1, hookEnd);
Assert.AreEqual(0, infoStart);
Assert.AreEqual(0, ctaStart);
}
[TestMethod]
public void PreviewSeekSeconds_OpeningAndClosing_StayInsideDuration()
{
// Act
double opening = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Opening, 30);
double closing = ShortsPipeline.PreviewSeekSeconds(ShortPreviewShot.Closing, 30);
// Assert
Assert.AreEqual(1.2, opening);
Assert.AreEqual(29.6, closing);
}
[TestMethod]
public void AddTimestampOffset_AddsFractionalSeconds()
{
// Act
string result = ShortsPipeline.AddTimestampOffset("00:01:10", 1.2);
// Assert
Assert.AreEqual("00:01:11.200", result);
}
[TestMethod]
public void BuildInputArguments_VideoStyle_IncludesLoopedBackgroundVideo()
{
// Arrange
ShortSettings settings = ValidSettings("in.mp4", "font.ttf", ShortStyle.Video) with
{
BackgroundVideo = @"D:\bg.mp4",
};
// Act
List<string> args = ShortsPipeline.BuildInputArguments(settings, 0.12, "00:00:01.200");
// Assert
CollectionAssert.Contains(args, "00:00:01.200");
CollectionAssert.Contains(args, "-stream_loop");
CollectionAssert.Contains(args, @"D:\bg.mp4");
CollectionAssert.Contains(args, "in.mp4");
}
[TestMethod]
public void CropPreviewOffsets_SpreadsFramesAcrossDuration()
{
// Act
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(30, 5);
// Assert
Assert.AreEqual(5, offsets.Count);
Assert.AreEqual(0, offsets[0]);
Assert.AreEqual(29.85, offsets[^1]);
Assert.AreEqual(14.925, offsets[2]);
}
[TestMethod]
public void CropPreviewOffsets_WithZeroDuration_ReturnsStartOnly()
{
// Act
IReadOnlyList<double> offsets = ShortsPipeline.CropPreviewOffsets(0);
// Assert
CollectionAssert.AreEqual(new[] { 0d }, offsets.ToArray());
}
[TestMethod]
public void FormatPreviewOffsetLabel_UnderOneHour_UsesMinutesAndSeconds()
{
// Act
string label = ShortsPipeline.FormatPreviewOffsetLabel(75);
// Assert
Assert.AreEqual("1:15", label);
}
[TestMethod]
public void BuildExtractFrameArguments_SeeksToStartAndWritesOnePng()
{
// Act
List<string> args = ShortsPipeline.BuildExtractFrameArguments(@"D:\clip.mp4", "00:00:05", @"C:\tmp\frame.png");
// Assert
CollectionAssert.Contains(args, "00:00:05");
CollectionAssert.Contains(args, @"D:\clip.mp4");
CollectionAssert.Contains(args, "-frames:v");
CollectionAssert.Contains(args, "1");
CollectionAssert.Contains(args, @"C:\tmp\frame.png");
}
[TestMethod]
public void BuildFilterComplex_WithoutTextOverlays_SkipsDrawtext()
{
// Act
string result = ShortsPipeline.BuildFilterComplex(
"[0:v]copy[base]",
fontEsc: "", hookEsc: "", titleEsc: "", metaEsc: "", websiteEsc: "",
hookEnd: 2.7, infoStart: 23, ctaStart: 26.5,
includeTextOverlays: false);
// Assert
StringAssert.Contains(result, "[0:v]copy[base]");
StringAssert.Contains(result, "[vout]");
Assert.IsFalse(result.Contains("drawtext", StringComparison.Ordinal));
Assert.IsFalse(result.Contains("drawbox", StringComparison.Ordinal));
}
}