Editor/HumanoidMocap/Video/FfmpegVideoDecoder.cs
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;

namespace HumanoidMocapX.EditorTools.Video;

/// <summary>Decodes with the system FFmpeg command-line tools (<c>ffmpeg</c>, <c>ffprobe</c>),
/// the Linux counterpart of <see cref="WindowsVideoDecoder"/>. The decoder process starts on the
/// first read and restarts on each seek. Dispose stops it.</summary>
public sealed class FfmpegVideoDecoder : IVideoDecoder
{
    const string Decoder = "ffmpeg", Probe = "ffprobe";
    // Decoding starts this far before a seek target so the frame displayed at it is included.
    const double SeekMargin = 1;
    readonly string path;
    readonly double origin;
    double start;
    Process process;
    Channel<(double Time, int Width, int Height)> frames;
    Queue<string> errors;
    Task stderr;

    /// <summary>Probes <paramref name="path"/>. Throws when it is missing, FFmpeg is not
    /// installed or the file has no video stream.</summary>
    public FfmpegVideoDecoder(string path)
    {
        if (!File.Exists(path)) throw new FileNotFoundException("Video not found.", path);
        this.path = Path.GetFullPath(path);
        (origin, Duration) = RunProbe(this.path);
    }

    public double Duration { get; }

    /// <summary>Whether <c>ffmpeg</c> and <c>ffprobe</c> are on <c>PATH</c>.</summary>
    public static bool IsInstalled => OnPath(Decoder) && OnPath(Probe);

    public void Seek(double seconds) { Stop(); start = Math.Max(0, seconds); }

    public DecodedVideoFrame Read(CancellationToken token)
    {
        token.ThrowIfCancellationRequested();
        if (process is null) Start();
        if (!frames.Reader.WaitToReadAsync(token).AsTask().GetAwaiter().GetResult())
        {
            process.WaitForExit();
            if (process.ExitCode != 0) throw new InvalidDataException("ffmpeg failed: " + string.Join(" | ", errors));
            return null;
        }
        frames.Reader.TryRead(out var frame);
        // Each frame is a new array: the timeline builder keeps the current and next frames alive.
        var rgba = new byte[checked(frame.Width * frame.Height * 4)];
        process.StandardOutput.BaseStream.ReadExactly(rgba);
        return new DecodedVideoFrame(rgba, frame.Width, frame.Height, frame.Time - origin);
    }

    void Start()
    {
        var info = new ProcessStartInfo(Decoder) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
        foreach (var argument in new[] { "-hide_banner", "-nostdin", "-loglevel", "info" }) info.ArgumentList.Add(argument);
        if (start > SeekMargin)
            foreach (var argument in new[] { "-ss", (start - SeekMargin).ToString("R", CultureInfo.InvariantCulture) }) info.ArgumentList.Add(argument);
        // showinfo reports each frame's timestamp and size just before its pixels are written.
        foreach (var argument in new[] { "-copyts", "-i", path, "-map", "0:v:0", "-vf", "showinfo", "-fps_mode", "passthrough",
            "-pix_fmt", "rgba", "-f", "rawvideo", "pipe:1" }) info.ArgumentList.Add(argument);
        frames = Channel.CreateUnbounded<(double, int, int)>(); errors = new();
        process = Process.Start(info) ?? throw new InvalidOperationException("Could not start ffmpeg.");
        var running = process; var output = frames; var log = errors;
        stderr = Task.Run(() => ReadLog(running, output, log));
    }

    static async Task ReadLog(Process process, Channel<(double, int, int)> frames, Queue<string> errors)
    {
        try
        {
            var (numerator, denominator) = (0L, 0L);
            while (await process.StandardError.ReadLineAsync() is { } line)
            {
                if (!line.Contains("Parsed_showinfo", StringComparison.Ordinal))
                {
                    errors.Enqueue(line);
                    if (errors.Count > 8) errors.Dequeue();
                }
                else if (Field(line, "config in time_base:") is { } timeBase) (numerator, denominator) = Rational(timeBase);
                else if (Field(line, " pts:") is { } pts && Field(line, " s:") is { } size)
                {
                    // Exact integer timestamps: pts_time is printed with only six decimals.
                    if (denominator <= 0) throw new InvalidDataException("ffmpeg reported a frame before its time base.");
                    var separator = size.IndexOf('x');
                    frames.Writer.TryWrite((long.Parse(pts, CultureInfo.InvariantCulture) * (double)numerator / denominator,
                        int.Parse(size[..separator], CultureInfo.InvariantCulture), int.Parse(size[(separator + 1)..], CultureInfo.InvariantCulture)));
                }
            }
            frames.Writer.TryComplete();
        }
        catch (Exception error) { frames.Writer.TryComplete(error); }
    }

    static (long Numerator, long Denominator) Rational(string text)
    {
        var parts = text.TrimEnd(',').Split('/');
        if (parts.Length != 2 || !long.TryParse(parts[0], CultureInfo.InvariantCulture, out var numerator)
            || !long.TryParse(parts[1], CultureInfo.InvariantCulture, out var denominator))
            throw new InvalidDataException($"Unreadable time base '{text}'.");
        return (numerator, denominator);
    }

    static string Field(string line, string name)
    {
        var at = line.IndexOf(name, StringComparison.Ordinal);
        if (at < 0) return null;
        var begin = at + name.Length;
        while (begin < line.Length && line[begin] == ' ') begin++;
        var end = line.IndexOf(' ', begin);
        return line[begin..(end < 0 ? line.Length : end)];
    }

    static (double Start, double Duration) RunProbe(string path)
    {
        var info = new ProcessStartInfo(Probe) { RedirectStandardOutput = true, RedirectStandardError = true, UseShellExecute = false };
        foreach (var argument in new[] { "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=start_pts,time_base:format=duration",
            "-of", "default=noprint_wrappers=1", path }) info.ArgumentList.Add(argument);
        Process probe;
        try { probe = Process.Start(info) ?? throw new InvalidOperationException("Could not start ffprobe."); }
        catch (System.ComponentModel.Win32Exception error)
        { throw new NotSupportedException("Decoding video here needs FFmpeg: install it so ffmpeg and ffprobe are on PATH.", error); }
        using (probe)
        {
            var output = probe.StandardOutput.ReadToEndAsync();
            var error = probe.StandardError.ReadToEnd();
            probe.WaitForExit();
            var values = output.GetAwaiter().GetResult().Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
                .Select(line => line.Split('=', 2)).Where(pair => pair.Length == 2).ToDictionary(pair => pair[0], pair => pair[1]);
            if (probe.ExitCode != 0 || !values.TryGetValue("duration", out var duration) || !values.TryGetValue("time_base", out var timeBase))
                throw new InvalidDataException($"ffprobe found no video stream with a duration in '{path}': {error.Trim()}");
            var (numerator, denominator) = Rational(timeBase);
            var first = long.TryParse(values.GetValueOrDefault("start_pts"), CultureInfo.InvariantCulture, out var pts) ? pts * (double)numerator / denominator : 0;
            return (first, double.Parse(duration, CultureInfo.InvariantCulture));
        }
    }

    static bool OnPath(string program) =>
        (Environment.GetEnvironmentVariable("PATH") ?? "").Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries)
            .Any(directory => File.Exists(Path.Combine(directory, program)));

    void Stop()
    {
        if (process is null) return;
        if (!process.HasExited) process.Kill(entireProcessTree: true);
        process.WaitForExit();
        stderr.Wait();
        process.Dispose(); process = null;
    }

    public void Dispose() => Stop();
}