Working version

This commit is contained in:
2026-08-11 12:34:02 +02:00
parent 438f163630
commit ad205bb534
12 changed files with 1006 additions and 179 deletions
+108 -21
View File
@@ -8,6 +8,12 @@ namespace AmigaDB.VideoRenderer.Services;
public sealed class RenderPipeline
{
private readonly ProcessRunner _runner = new();
private readonly MediaProbe _probe;
public RenderPipeline()
{
_probe = new MediaProbe(_runner, null);
}
public async Task RenderAsync(
RenderSettings settings,
@@ -17,6 +23,8 @@ public sealed class RenderPipeline
{
Validate(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
tools = await PrepareToolsAsync(tools, log, token);
MediaProbe probe = new(_runner, log);
Directory.CreateDirectory(settings.OutputDirectory);
string workDir = Path.Combine(Path.GetTempPath(), "AmiReel", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(workDir);
@@ -32,6 +40,7 @@ public sealed class RenderPipeline
string concat = Path.Combine(workDir, "inputs.txt");
await File.WriteAllLinesAsync(concat, settings.InputFiles.Select(f => $"file '{EscapeConcat(f)}'"), token);
string encoder = await SelectEncoderAsync(tools, settings.Encoder, log, token);
double joinDuration = await ProbeCombinedDurationAsync(probe, tools, settings.InputFiles, token);
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
@@ -39,9 +48,9 @@ public sealed class RenderPipeline
"-r", settings.FramesPerSecond.ToString(CultureInfo.InvariantCulture)];
joinArgs.AddRange(EncoderArguments(encoder));
joinArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", main]);
await RunStageAsync(tools.Ffmpeg, joinArgs, log, progress, 2, 48, null, token);
await RunStageAsync(tools.Ffmpeg, joinArgs, log, progress, "Joining recordings", 2, 48, joinDuration, token);
double originalDuration = await ProbeDurationAsync(tools.Ffprobe, main, token);
double originalDuration = await probe.ProbeDurationAsync(tools, main, token);
double trimmedDuration = originalDuration - settings.TrimStart;
if (trimmedDuration <= 0)
throw new InvalidOperationException("Trim start is beyond the end of the video.");
@@ -84,7 +93,7 @@ public sealed class RenderPipeline
double fadeStart = Math.Max(0, trimmedDuration - settings.FadeSeconds);
double finalDuration = trimmedDuration + settings.EndCardHoldSeconds;
double cardDuration = finalDuration + 2;
bool hasAudio = await HasAudioAsync(tools.Ffprobe, main, token);
bool hasAudio = await probe.HasAudioAsync(tools, main, token);
progress.Report(new(60, "Final render", "Applying trim, fade and AmiReel end card"));
List<string> finalArgs = ["-y", "-ss", F(settings.TrimStart), "-i", main,
@@ -95,7 +104,7 @@ public sealed class RenderPipeline
"-map", "[v]", "-map", "[a]", "-r", settings.FramesPerSecond.ToString()]);
finalArgs.AddRange(EncoderArguments(encoder));
finalArgs.AddRange(["-c:a", "aac", "-b:a", "384k", "-ar", "48000", "-movflags", "+faststart", final]);
await RunStageAsync(tools.Ffmpeg, finalArgs, log, progress, 60, 99, finalDuration, token);
await RunStageAsync(tools.Ffmpeg, finalArgs, log, progress, "Final render", 60, 99, finalDuration, token);
progress.Report(new(100, "Complete", final));
}
finally
@@ -127,6 +136,73 @@ public sealed class RenderPipeline
return "x264";
}
private async Task<ToolPaths> PrepareToolsAsync(ToolPaths tools, Action<string> log, CancellationToken token)
{
try
{
await ValidateToolAsync(
tools.Ffmpeg,
["-version"],
"FFmpeg",
"Configure AmiReel to use a real standalone FFmpeg build, or replace the embedded ThirdParty binaries with standalone ffmpeg.exe and ffprobe.exe files.",
token);
await ValidateToolAsync(
tools.Ffprobe,
["-version"],
"FFprobe",
"Configure AmiReel to use a real standalone FFprobe build, or replace the embedded ThirdParty binaries with standalone ffmpeg.exe and ffprobe.exe files.",
token);
return tools;
}
catch (InvalidOperationException)
{
if (!ToolExtractor.TryResolveFromSystemPath(out ToolPaths? systemTools) || systemTools is null)
throw;
await ValidateToolAsync(
systemTools.Ffmpeg,
["-version"],
"FFmpeg",
"AmiReel found FFmpeg on PATH, but it could not be started.",
token);
await ValidateToolAsync(
systemTools.Ffprobe,
["-version"],
"FFprobe",
"AmiReel found FFprobe on PATH, but it could not be started.",
token);
log($"Configured or embedded FFmpeg tools are unusable. Falling back to FFmpeg from PATH: '{systemTools.Ffmpeg}' and '{systemTools.Ffprobe}'.");
return systemTools;
}
}
private async Task ValidateToolAsync(
string executable,
IEnumerable<string> arguments,
string toolName,
string guidance,
CancellationToken token)
{
try
{
await _runner.RunAsync(executable, arguments, null, null, token);
}
catch (InvalidOperationException exception) when (exception.Message.Contains("Cannot find file at", StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException(
$"{toolName} is not a standalone binary and points to missing companion files.\n{guidance}", exception);
}
catch (InvalidOperationException exception)
{
throw new InvalidOperationException(
$"{toolName} could not be started from '{executable}'.\n{guidance}", exception);
}
}
private static IEnumerable<string> EncoderArguments(string encoder) => encoder == "nvenc"
? ["-c:v", "h264_nvenc", "-preset", "p6", "-tune", "hq", "-rc", "vbr", "-cq", "16", "-b:v", "0",
"-maxrate", "100M", "-bufsize", "200M", "-multipass", "fullres", "-spatial-aq", "1", "-aq-strength", "8",
@@ -150,30 +226,41 @@ public sealed class RenderPipeline
}
}
private static async Task<double> ProbeCombinedDurationAsync(MediaProbe probe, ToolPaths tools, IEnumerable<string> inputs, CancellationToken token)
{
double total = 0;
foreach (string input in inputs)
total += await probe.ProbeDurationAsync(tools, input, token);
return total;
}
private async Task RunStageAsync(string executable, IEnumerable<string> args, Action<string> log,
IProgress<RenderProgress> progress, double from, double to, double? duration, CancellationToken token)
IProgress<RenderProgress> progress, string stageName, double from, double to, double? duration, CancellationToken token)
{
await _runner.RunAsync(executable, args, log, time =>
await _runner.RunAsync(executable, args, log, null, token, outputHandler: output =>
{
if (duration > 0)
progress.Report(new(from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from), "Rendering", time.ToString(@"hh\:mm\:ss")));
}, token);
if (duration is not > 0 || output.Time is not { } time)
return;
double percent = from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from);
string message = BuildProgressMessage(time, duration.Value, output.FramesPerSecond, output.Speed, output.Frame);
progress.Report(new(percent, stageName, message));
});
}
private async Task<double> ProbeDurationAsync(string ffprobe, string input, CancellationToken token)
private static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame)
{
string output = await _runner.RunAsync(ffprobe,
["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input],
null, null, token);
return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
}
TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"];
private async Task<bool> HasAudioAsync(string ffprobe, string input, CancellationToken token)
{
string output = await _runner.RunAsync(ffprobe,
["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input],
null, null, token);
return !string.IsNullOrWhiteSpace(output);
if (fps is > 0)
parts.Add($"{fps:0.#} fps");
if (speed is > 0)
parts.Add($"{speed:0.##}x");
if (frame is > 0)
parts.Add($"frame {frame.Value:N0}");
return string.Join(" · ", parts);
}
private static string BuildFinalFilter(RenderSettings s, double trimmed, double fadeStart, double finalDuration, bool hasAudio)