Working version
This commit is contained in:
+108
-21
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user