OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell.Tests / FfmpegPipeTests.cs

403 lines · csharp ref:
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)));
    }
}