OverviewFilesCommitsIssuesDocsReleasesPeers

ifsrenderer-linux / AvaloniaShell / FfmpegFramePipe.cs

322 lines · csharp ref:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

namespace IFSRenderer.AvaloniaShell;

/// <summary>
/// Streams rendered frames straight into ffmpeg's stdin, so a clip export produces a video
/// without ever writing a PNG sequence.
/// </summary>
/// <remarks>
/// <para>
/// The frames were previously written to disk as <c>frame_%05d.png</c> and ffmpeg was run over
/// them afterwards. Piping removes the intermediate entirely: one 1080p frame is ~8MB as raw
/// BGRA and a 10s clip is 300 of them, so the old path wrote ~2.5GB of PNGs to produce a file
/// of a few tens of MB.
/// </para>
/// <para>
/// The trade is that there is no longer a fallback on disk, so this fails EARLY and LOUDLY.
/// <see cref="Start"/> runs before the first frame is rendered and throws if ffmpeg cannot be
/// launched, rather than discovering it after a render that may have taken an hour. If ffmpeg
/// dies partway, the next <see cref="Write"/> throws on the broken pipe and the export stops
/// there instead of rendering the rest of the clip into a closed socket.
/// </para>
/// <para>
/// Cancellation closes stdin rather than killing the process, so ffmpeg finalises the container
/// and the partial file is playable. Killing it would leave an mp4 with no moov atom.
/// </para>
/// </remarks>
internal sealed class FfmpegFramePipe : IDisposable
{
    /// <summary>Kept for the error message; ffmpeg says little on stdout and plenty on stderr.</summary>
    private readonly StringBuilder _stderr = new();

    private Process _process;
    private Stream _stdin;
    private Task _drain;

    public FfmpegFramePipe(string exePath, string userArgs, string outputPath,
                           string audioPath = null, double? durationSeconds = null)
    {
        ExePath = string.IsNullOrWhiteSpace(exePath) ? "ffmpeg" : exePath;
        UserArgs = string.IsNullOrWhiteSpace(userArgs) ? DefaultArgs : userArgs;
        OutputPath = outputPath;
        AudioPath = audioPath;
        DurationSeconds = durationSeconds;
    }

    public const string DefaultArgs = "-c:v libx264 -pix_fmt yuv420p -crf 18";

    public string ExePath { get; }
    public string UserArgs { get; }
    public string OutputPath { get; }

    /// <summary>The loaded audio clip, muxed alongside the frames. Null for a silent export.</summary>
    public string AudioPath { get; }

    /// <summary>Clip length, so a longer audio track is cut to the animation rather than
    /// extending the video past its last frame.</summary>
    public double? DurationSeconds { get; }

    /// <summary>Frames handed to <see cref="Write"/> so far.</summary>
    public int FramesWritten { get; private set; }

    /// <summary>
    /// Chooses a container from the codec in the user's arguments, as the canonical does
    /// (AnimationViewModel.cs:455-458).
    /// </summary>
    public static string ExtensionFor(string args)
    {
        args ??= "";
        if (args.Contains("prores", StringComparison.OrdinalIgnoreCase))
            return ".mov";
        if (args.Contains("libvpx", StringComparison.OrdinalIgnoreCase))
            return ".webm";
        return ".mp4";
    }

    /// <summary>
    /// The full argument list. Input geometry is fixed by the render, so it is ours to set;
    /// everything about the encode comes from the user.
    /// </summary>
    internal static List<string> BuildArgs(int width, int height, int fps, string userArgs,
                                          string outputPath, string audioPath = null,
                                          double? durationSeconds = null)
    {
        var args = new List<string>
        {
            "-y",
            "-nostdin",           // never let ffmpeg try to read the console; stdin is frame data
            "-f", "rawvideo",
            "-pix_fmt", "bgra",   // exactly what RendererGL.CopyPixelDataToBitmap emits
            "-s", $"{width}x{height}",
            "-framerate", fps.ToString(),
            "-i", "-",
        };

        // The animation panel can load an audio clip and drive channels from it, so a user can
        // watch a track in the scrubber, export, and get silence. The canonical muxes it as a
        // second input; -t then cuts a longer track back to the animation rather than letting
        // the video run on past its last frame.
        if (!string.IsNullOrWhiteSpace(audioPath))
        {
            args.Add("-i");
            args.Add(audioPath);
        }
        if (durationSeconds is > 0)
        {
            args.Add("-t");
            args.Add(durationSeconds.Value.ToString("0.###",
                System.Globalization.CultureInfo.InvariantCulture));
        }
        // h264/h265 with yuv420p need even dimensions, and the export resolution is whatever the
        // user typed - the app's own preview resolution is 1407x791. Without this ffmpeg refuses
        // the stream outright (exit 187), so an odd resolution encodes on Windows, where
        // AnimationViewModel.RunFfmpegProcess pads unconditionally, and failed here.
        //
        // Only when actually needed, and never over the user's own filter: a second -vf would
        // silently replace theirs rather than combine with it, so if they brought one it is
        // theirs to get right.
        if ((width % 2 != 0 || height % 2 != 0) && !HasVideoFilter(userArgs))
        {
            args.Add("-vf");
            args.Add("pad=ceil(iw/2)*2:ceil(ih/2)*2");
        }

        args.AddRange(SplitArgs(string.IsNullOrWhiteSpace(userArgs) ? DefaultArgs : userArgs));
        args.Add(outputPath);
        return args;
    }

    /// <summary>
    /// Resolves the ffmpeg the export will actually run, or null if there is none.
    /// </summary>
    /// <remarks>
    /// A blank setting means "whatever is on PATH", which is the normal case on Linux where
    /// ffmpeg is a package rather than something the user downloads. The canonical DISABLES
    /// video export until an explicit path is set, because on Windows it rarely is on PATH —
    /// copying that here would demand a browse from users who need to do nothing. So the path
    /// stays an override and this exists to report what will run, not to gate on it.
    /// </remarks>
    public static string Resolve(string configured)
    {
        if (!string.IsNullOrWhiteSpace(configured))
            return File.Exists(configured) ? configured : null;

        foreach (string dir in (Environment.GetEnvironmentVariable("PATH") ?? "")
                     .Split(Path.PathSeparator))
        {
            if (dir.Length == 0)
                continue;
            foreach (string name in new[] { "ffmpeg", "ffmpeg.exe" })
            {
                string candidate = Path.Combine(dir, name);
                if (File.Exists(candidate))
                    return candidate;
            }
        }
        return null;
    }

    /// <summary>Whether the user already drives the video filter graph themselves.</summary>
    internal static bool HasVideoFilter(string userArgs)
    {
        foreach (string token in SplitArgs(userArgs ?? ""))
        {
            if (token is "-vf" or "-filter:v" or "-filter_complex" or "-af" or "-filter:a")
                return true;
        }
        return false;
    }

    /// <summary>
    /// Launches ffmpeg. Call BEFORE rendering anything: a failure here must cost nothing.
    /// </summary>
    public void Start(int width, int height, int fps)
    {
        var psi = new ProcessStartInfo(ExePath)
        {
            UseShellExecute = false,
            RedirectStandardInput = true,
            RedirectStandardError = true,
            WorkingDirectory = Path.GetDirectoryName(OutputPath) ?? ".",
        };
        foreach (string arg in BuildArgs(width, height, fps, UserArgs, OutputPath,
                                         AudioPath, DurationSeconds))
            psi.ArgumentList.Add(arg);

        try
        {
            _process = Process.Start(psi);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException(
                $"Could not start ffmpeg ('{ExePath}'): {ex.Message}. "
                + "Set its path in Settings > Animations, or untick video export to keep frames.", ex);
        }

        if (_process is null)
            throw new InvalidOperationException($"Could not start ffmpeg ('{ExePath}').");

        _stdin = _process.StandardInput.BaseStream;

        // Drained continuously: a full stderr pipe would block ffmpeg forever, and a blocked
        // ffmpeg blocks our writes, which is a deadlock rather than an error.
        _drain = Task.Run(async () =>
        {
            string line;
            while ((line = await _process.StandardError.ReadLineAsync()) is not null)
                lock (_stderr)
                {
                    _stderr.AppendLine(line);
                    if (_stderr.Length > 8192)
                        _stderr.Remove(0, _stderr.Length - 8192);
                }
        });
    }

    /// <summary>Hands one frame to ffmpeg. Blocks if ffmpeg is behind, which is the backpressure.</summary>
    public void Write(ReadOnlySpan<byte> bgra)
    {
        if (_stdin is null)
            throw new InvalidOperationException("The ffmpeg pipe was not started.");

        try
        {
            _stdin.Write(bgra);
            FramesWritten++;
        }
        catch (IOException ex)
        {
            // Broken pipe: ffmpeg exited. Its own complaint is far more useful than ours.
            throw new InvalidOperationException(
                "ffmpeg stopped accepting frames after " + FramesWritten + ". " + Tail(), ex);
        }
    }

    /// <summary>
    /// Closes stdin and waits for ffmpeg to finalise the container.
    /// </summary>
    /// <returns>null on success, otherwise why it failed.</returns>
    public string Finish()
    {
        if (_process is null)
            return "The ffmpeg pipe was not started.";

        try
        {
            _stdin?.Flush();
            _stdin?.Dispose();
            _stdin = null;
        }
        catch (IOException)
        {
            // ffmpeg already gone; its exit code and stderr below are what matter.
        }

        if (!_process.WaitForExit(120_000))
        {
            try { _process.Kill(true); } catch { /* already gone */ }
            return "ffmpeg did not finish within 120s and was stopped. " + Tail();
        }

        try { _drain?.Wait(5_000); } catch { /* drained best-effort */ }

        return _process.ExitCode == 0 ? null : $"ffmpeg exited with code {_process.ExitCode}. {Tail()}";
    }

    /// <summary>The last meaningful line ffmpeg printed, for a one-line status message.</summary>
    private string Tail()
    {
        lock (_stderr)
        {
            return _stderr.ToString()
                .Split('\n')
                .Select(l => l.Trim())
                .LastOrDefault(l => l.Length > 0) ?? "";
        }
    }

    /// <summary>Splits a user argument string, honouring double quotes around paths.</summary>
    internal static IEnumerable<string> SplitArgs(string args)
    {
        var current = new StringBuilder();
        bool quoted = false;
        foreach (char c in args ?? "")
        {
            if (c == '"')
                quoted = !quoted;
            else if (char.IsWhiteSpace(c) && !quoted)
            {
                if (current.Length > 0)
                {
                    yield return current.ToString();
                    current.Clear();
                }
            }
            else
                current.Append(c);
        }
        if (current.Length > 0)
            yield return current.ToString();
    }

    public void Dispose()
    {
        try { _stdin?.Dispose(); } catch { /* closing down */ }
        try
        {
            if (_process is { HasExited: false })
                _process.Kill(true);
        }
        catch { /* already gone */ }
        _process?.Dispose();
    }
}