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();
}
}