using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using IFSRenderer.AvaloniaShell;
using Xunit;
using Xunit.Abstractions;
namespace IFSRenderer.AvaloniaShell.Tests;
/// <summary>
/// Clip export streams frames into ffmpeg instead of writing a png sequence.
/// </summary>
/// <remarks>
/// The encode tests need a real ffmpeg and are skipped without one, so the suite still runs on
/// a machine that has none. They are worth the dependency: the whole point of removing the png
/// sequence is that there is no longer a fallback on disk, so "ffmpeg accepted our bytes and
/// produced a file with the right number of frames in it" is the only assertion that means
/// anything. Argument-shape tests alone would pass just as happily on a stream ffmpeg rejects.
/// </remarks>
public class FfmpegPipeTests
{
private readonly ITestOutputHelper _output;
public FfmpegPipeTests(ITestOutputHelper output) => _output = output;
/// <summary>
/// Reports a skip rather than passing silently, so a machine without ffmpeg says so in the
/// log instead of appearing to have covered the encode.
/// </summary>
private bool Encoding([System.Runtime.CompilerServices.CallerMemberName] string test = null)
{
if (HasFfmpeg)
return true;
_output.WriteLine($"SKIPPED {test}: ffmpeg and ffprobe are not both on PATH.");
return false;
}
private static bool HasFfmpeg => Which("ffmpeg") is not null && Which("ffprobe") is not null;
private static string Which(string exe)
{
foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? "").Split(':'))
{
if (dir.Length == 0)
continue;
string candidate = Path.Combine(dir, exe);
if (File.Exists(candidate))
return candidate;
}
return null;
}
// ------------------------------------------------------------------ arguments
[Theory]
[InlineData("-c:v libx264 -pix_fmt yuv420p -crf 18", ".mp4")]
[InlineData("-c:v prores_ks -profile:v 3", ".mov")]
[InlineData("-c:v libvpx-vp9 -crf 30", ".webm")]
[InlineData("", ".mp4")]
[InlineData(null, ".mp4")]
public void ContainerFollowsTheCodec(string args, string expected) =>
Assert.Equal(expected, FfmpegFramePipe.ExtensionFor(args));
[Fact]
public void InputIsDescribedExactlyAsTheRendererEmitsIt()
{
List<string> args = FfmpegFramePipe.BuildArgs(1920, 1080, 30, "-c:v libx264", "out.mp4");
string line = string.Join(" ", args);
// RendererGL.CopyPixelDataToBitmap emits BGRA8; a mismatch here silently swaps the
// colour channels rather than failing, so it is asserted rather than assumed.
Assert.Contains("-f rawvideo", line);
Assert.Contains("-pix_fmt bgra", line);
Assert.Contains("-s 1920x1080", line);
Assert.Contains("-framerate 30", line);
Assert.Contains("-i -", line);
// Geometry must precede the input it describes, and the output comes last.
Assert.True(args.IndexOf("rawvideo") < args.IndexOf("-i"));
Assert.Equal("out.mp4", args[^1]);
// stdin carries frame data, so ffmpeg must never try to read the console from it.
Assert.Contains("-nostdin", args);
}
[Fact]
public void UserArgumentsSurviveQuotingAndDefaultWhenBlank()
{
Assert.Equal(["-vf", "scale=2*trunc(iw/2):-2"],
FfmpegFramePipe.SplitArgs("-vf \"scale=2*trunc(iw/2):-2\"").ToArray());
Assert.Contains("libx264", string.Join(" ", FfmpegFramePipe.BuildArgs(16, 16, 30, "", "o.mp4")));
}
// -------------------------------------------------------------------- encoding
private static byte[] Frame(int w, int h, byte value)
{
var bgra = new byte[w * h * 4];
Array.Fill(bgra, value);
return bgra;
}
private static int CountFrames(string path)
{
var psi = new ProcessStartInfo(Which("ffprobe"))
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string a in new[]
{
"-v", "error", "-select_streams", "v:0", "-count_frames",
"-show_entries", "stream=nb_read_frames", "-of", "csv=p=0", path,
})
psi.ArgumentList.Add(a);
using Process probe = Process.Start(psi);
string output = probe.StandardOutput.ReadToEnd().Trim();
probe.WaitForExit();
return int.TryParse(output, out int frames) ? frames : -1;
}
[Fact]
public void EveryPipedFrameReachesTheFile()
{
if (!Encoding())
return;
string dir = Path.Combine(Path.GetTempPath(), "ifs-pipe-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
string output = Path.Combine(dir, "animation.mp4");
using var pipe = new FfmpegFramePipe(null, null, output);
pipe.Start(32, 32, 10);
for (int i = 0; i < 12; i++)
pipe.Write(Frame(32, 32, (byte)(i * 20)));
string failure = pipe.Finish();
Assert.Null(failure);
Assert.Equal(12, pipe.FramesWritten);
Assert.True(File.Exists(output), "no file was produced");
Assert.True(new FileInfo(output).Length > 0, "the file is empty");
Assert.Equal(12, CountFrames(output));
// The point of the change: nothing else was written.
Assert.Empty(Directory.GetFiles(dir, "*.png"));
}
finally { Directory.Delete(dir, true); }
}
/// <summary>
/// h264/h265 with yuv420p require even dimensions, and the export resolution is whatever
/// the user typed - the canonical's own preview resolution is 1407x791. WPF pads for this
/// (AnimationViewModel.RunFfmpegProcess: -vf "pad=ceil(iw/2)*2:ceil(ih/2)*2"); without it
/// ffmpeg refuses the stream outright, so an odd resolution exports on Windows and fails
/// here. A real encode is the only assertion worth making: an argument-shape test would
/// pass against a stream ffmpeg rejects.
/// </summary>
[Fact]
public void AnOddResolutionStillEncodes()
{
if (!Encoding())
return;
string dir = Path.Combine(Path.GetTempPath(), "ifs-odd-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
const int W = 101, H = 63; // both odd, as 1407x791 is
string output = Path.Combine(dir, "odd.mp4");
using var pipe = new FfmpegFramePipe(null, "-c:v libx264 -pix_fmt yuv420p", output);
pipe.Start(W, H, 10);
for (int i = 0; i < 6; i++)
pipe.Write(Frame(W, H, (byte)(i * 30)));
string failure = pipe.Finish();
Assert.True(failure is null,
"ffmpeg rejected an odd resolution; the pad filter is missing: " + failure);
Assert.Equal(6, CountFrames(output));
(int w, int h) = Dimensions(output);
Assert.True(w % 2 == 0 && h % 2 == 0, $"encoded dimensions {w}x{h} are not even");
}
finally { Directory.Delete(dir, true); }
}
private static (int, int) Dimensions(string path)
{
var psi = new ProcessStartInfo(Which("ffprobe"))
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string a in new[]
{
"-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=width,height", "-of", "csv=p=0", path,
})
psi.ArgumentList.Add(a);
using Process probe = Process.Start(psi);
string output = probe.StandardOutput.ReadToEnd().Trim();
probe.WaitForExit();
string[] parts = output.Split(',');
return parts.Length == 2 && int.TryParse(parts[0], out int w) && int.TryParse(parts[1], out int h)
? (w, h) : (-1, -1);
}
/// <summary>
/// A blank path means "whatever is on PATH", which is the normal case on Linux. The
/// canonical gates video export on an explicit path because ffmpeg is rarely on PATH under
/// Windows; copying that would demand a browse from users who need to do nothing, so the
/// path is an override and this only reports what will run.
/// </summary>
[Fact]
public void BlankPathResolvesToWhateverIsOnPath()
{
string resolved = FfmpegFramePipe.Resolve("");
if (!HasFfmpeg)
{
_output.WriteLine("SKIPPED: no ffmpeg on PATH.");
return;
}
Assert.NotNull(resolved);
Assert.True(File.Exists(resolved), $"resolved to '{resolved}', which does not exist");
}
[Fact]
public void AnExplicitPathIsUsedWhenItExistsAndRejectedWhenItDoesNot()
{
string real = Which("ffmpeg");
if (real is not null)
Assert.Equal(real, FfmpegFramePipe.Resolve(real));
Assert.Null(FfmpegFramePipe.Resolve("/nonexistent/definitely/not/ffmpeg"));
}
/// <summary>
/// The pad filter must not be bolted on top of a filter the user brought: a second -vf
/// REPLACES the first rather than combining, so theirs would silently stop applying.
/// </summary>
[Fact]
public void AUserSuppliedFilterIsLeftAlone()
{
List<string> args = FfmpegFramePipe.BuildArgs(
101, 63, 30, "-vf hflip -c:v libx264", "o.mp4");
Assert.Single(args, a => a == "-vf");
Assert.DoesNotContain(args, a => a.Contains("pad=", StringComparison.Ordinal));
}
[Fact]
public void EvenResolutionsAreNotFilteredAtAll()
{
List<string> args = FfmpegFramePipe.BuildArgs(1920, 1080, 30, "-c:v libx264", "o.mp4");
Assert.DoesNotContain(args, a => a == "-vf");
}
/// <summary>
/// The animation panel can load an audio clip and drive channels from it, so a user sees the
/// track in the scrubber, exports, and gets silence. The canonical muxes it
/// (AnimationViewModel.RunFfmpegProcess: a second -i plus -t to cut audio to the animation
/// length); this port dropped it with no warning.
/// </summary>
[Fact]
public void AudioIsMuxedAndCutToTheAnimationLength()
{
if (!Encoding())
return;
string dir = Path.Combine(Path.GetTempPath(), "ifs-audio-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
// A 5s tone against a 1.2s animation: long on purpose, so -t has something to cut.
string wav = Path.Combine(dir, "tone.wav");
RunFfmpeg("-y", "-f", "lavfi", "-i", "sine=frequency=440:duration=5", wav);
Assert.True(File.Exists(wav), "could not synthesise the test tone");
string output = Path.Combine(dir, "with-audio.mp4");
using var pipe = new FfmpegFramePipe(null, null, output, wav, 1.2);
pipe.Start(32, 32, 10);
for (int i = 0; i < 12; i++) // 12 frames @ 10fps = 1.2s
pipe.Write(Frame(32, 32, (byte)(i * 20)));
string failure = pipe.Finish();
Assert.True(failure is null, "encode with audio failed: " + failure);
Assert.True(HasAudioStream(output), "the muxed file has no audio stream");
double seconds = DurationOf(output);
Assert.True(seconds < 2.0,
$"duration {seconds:0.00}s — the 5s tone was not cut to the animation length");
}
finally { Directory.Delete(dir, true); }
}
[Fact]
public void NoAudioInputWhenNoClipIsLoaded()
{
List<string> args = FfmpegFramePipe.BuildArgs(64, 64, 30, "-c:v libx264", "o.mp4", null, null);
Assert.Single(args, a => a == "-i");
Assert.DoesNotContain(args, a => a == "-t");
}
private static void RunFfmpeg(params string[] arguments)
{
var psi = new ProcessStartInfo(Which("ffmpeg"))
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string a in arguments)
psi.ArgumentList.Add(a);
using Process p = Process.Start(psi);
p.StandardError.ReadToEnd();
p.WaitForExit();
}
private static string Probe(params string[] arguments)
{
var psi = new ProcessStartInfo(Which("ffprobe"))
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
foreach (string a in arguments)
psi.ArgumentList.Add(a);
using Process p = Process.Start(psi);
string o = p.StandardOutput.ReadToEnd().Trim();
p.WaitForExit();
return o;
}
private static bool HasAudioStream(string path) =>
Probe("-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_type",
"-of", "csv=p=0", path).Contains("audio", StringComparison.Ordinal);
private static double DurationOf(string path) =>
double.TryParse(Probe("-v", "error", "-show_entries", "format=duration",
"-of", "csv=p=0", path),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out double d) ? d : -1;
/// <summary>A cancelled export must still leave something playable.</summary>
[Fact]
public void ClosingEarlyLeavesAValidPartialFile()
{
if (!Encoding())
return;
string dir = Path.Combine(Path.GetTempPath(), "ifs-pipe-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(dir);
try
{
string output = Path.Combine(dir, "animation.mp4");
using var pipe = new FfmpegFramePipe(null, null, output);
pipe.Start(32, 32, 10);
for (int i = 0; i < 4; i++) // stop well short, as Cancel does
pipe.Write(Frame(32, 32, 128));
Assert.Null(pipe.Finish());
Assert.Equal(4, CountFrames(output));
}
finally { Directory.Delete(dir, true); }
}
/// <summary>
/// A bad ffmpeg path must fail BEFORE anything is rendered, not after.
/// </summary>
[Fact]
public void AMissingFfmpegFailsAtStartRatherThanAfterTheRender()
{
using var pipe = new FfmpegFramePipe(
"/nonexistent/ffmpeg-that-is-not-here", null, "/tmp/never-written.mp4");
var ex = Assert.Throws<InvalidOperationException>(() => pipe.Start(32, 32, 30));
Assert.Contains("ffmpeg", ex.Message);
Assert.False(File.Exists("/tmp/never-written.mp4"));
}
[Fact]
public void WritingBeforeStartIsRejected()
{
using var pipe = new FfmpegFramePipe(null, null, "/tmp/unused.mp4");
Assert.Throws<InvalidOperationException>(() => pipe.Write(Frame(4, 4, 0)));
}
}