Files
AmiReel/Services/RenderPipeline.cs
T
klevze a590904e89 Add video preview, source crop, find-videos, and render toasts
Bring the Video tab closer to Shorts with 4K preview stills and crop
modes (none, auto, center, plus the visual editor). Settings store a
default source folder so Find videos can collect output.avi sequences.
Cap the FFmpeg log with auto-scroll, hide repeat spam, and show a
Windows notification when a video or short finishes or fails.
2026-08-16 09:20:56 +02:00

512 lines
24 KiB
C#

using System.Globalization;
using System.IO;
using System.Text;
using AmiReel.Models;
namespace AmiReel.Services;
public sealed class RenderPipeline
{
private readonly ProcessRunner _runner = new();
private readonly MediaProbe _probe;
public RenderPipeline()
{
_probe = new MediaProbe(_runner, null);
}
public async Task<IReadOnlyList<string>> RenderAsync(
RenderSettings settings,
IProgress<RenderProgress> progress,
Action<string> log,
CancellationToken token)
{
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);
string endCardPath = await ResolveEndCardPathAsync(settings, workDir, token);
string main = Path.Combine(workDir, settings.OutputName + "_4k50.mp4");
string final = Path.Combine(settings.OutputDirectory, settings.OutputName + "_final.mp4");
string thumbs = Path.Combine(settings.OutputDirectory, settings.OutputName + "_thumbnails");
string webp = Path.Combine(settings.OutputDirectory, settings.OutputName + "_screenshots.webp");
try
{
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"));
string cropFilter = await ResolveSourceCropFilterAsync(tools, settings, log, token);
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
"-vf", BuildJoinVideoFilter(settings, cropFilter),
"-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, "Joining recordings", 2, 48, joinDuration, settings.FramesPerSecond, 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.");
Directory.CreateDirectory(thumbs);
int count = Math.Max(0, (int)Math.Floor((trimmedDuration - 0.001) / settings.ThumbnailInterval));
for (int index = 1; index <= count; index++)
{
token.ThrowIfCancellationRequested();
int timestamp = index * settings.ThumbnailInterval;
double seek = timestamp + settings.TrimStart;
string label = timestamp.ToString("0000", CultureInfo.InvariantCulture) + "s";
string prefix = Path.Combine(thumbs, $"{settings.OutputName}_thumb_{label}");
await TryThumbnailAsync(tools.Ffmpeg, main, seek, prefix + ".png", settings, true, log, token);
await TryThumbnailAsync(tools.Ffmpeg, main, seek, prefix + ".jpg", settings, false, log, token);
progress.Report(new(48 + (count == 0 ? 8 : 8d * index / count), "Thumbnails", $"Thumbnail {index} of {count}"));
}
string[] pngs = Directory.GetFiles(thumbs, "*.png");
if (pngs.Length > 0)
{
progress.Report(new(57, "Animated preview", "Creating animated WebP"));
string previewList = Path.Combine(workDir, "preview-images.txt");
List<string> previewLines = [];
foreach (string png in pngs.OrderBy(p => Path.GetFileName(p), StringComparer.OrdinalIgnoreCase))
{
previewLines.Add($"file '{EscapeConcat(png)}'");
previewLines.Add("duration 1");
}
// The concat demuxer requires the final image to be repeated so
// its duration is honored.
previewLines.Add($"file '{EscapeConcat(pngs.OrderBy(p => Path.GetFileName(p), StringComparer.OrdinalIgnoreCase).Last())}'");
await File.WriteAllLinesAsync(previewList, previewLines, token);
await _runner.RunAsync(tools.Ffmpeg,
["-y", "-f", "concat", "-safe", "0", "-i", previewList, "-vf", "fps=1",
"-loop", "0", "-c:v", "libwebp", "-quality", "80", "-compression_level", "6", webp],
log, null, token, allowFailure: true);
}
double fadeStart = Math.Max(0, trimmedDuration - settings.FadeSeconds);
double finalDuration = trimmedDuration + settings.EndCardHoldSeconds;
double cardDuration = finalDuration + 2;
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,
"-loop", "1", "-framerate", settings.FramesPerSecond.ToString(), "-t", F(cardDuration), "-i", endCardPath];
if (!hasAudio)
finalArgs.AddRange(["-f", "lavfi", "-t", F(finalDuration), "-i", "anullsrc=channel_layout=stereo:sample_rate=48000"]);
finalArgs.AddRange(["-filter_complex", BuildFinalFilter(settings, trimmedDuration, fadeStart, finalDuration, hasAudio),
"-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, "Final render", 60, 99, finalDuration, settings.FramesPerSecond, token);
IReadOnlyList<string> inputPaths = settings.InputFiles;
if (settings.MoveSourcesToOriginals)
{
progress.Report(new(99.5, "Moving sources", "Moving original recordings to originals"));
inputPaths = MoveSourceVideos(settings.InputFiles, settings.OutputDirectory, log);
}
progress.Report(new(100, "Complete", final));
return inputPaths;
}
finally
{
try { Directory.Delete(workDir, true); } catch { }
}
}
/// <summary>
/// Renders two 16:9 stills with the same crop/scale/pad as the final video: a source frame and the end card.
/// </summary>
public async Task<VideoPreviewFrames> RenderPreviewAsync(
RenderSettings settings, string outputDirectory, Action<string> log, CancellationToken token)
{
ValidatePreview(settings);
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
Directory.CreateDirectory(outputDirectory);
string cropFilter = await ResolveSourceCropFilterAsync(tools, settings, log, token);
string joinFilter = BuildJoinVideoFilter(settings, cropFilter);
string openingPath = Path.Combine(outputDirectory, "opening.png");
string closingPath = Path.Combine(outputDirectory, "closing.png");
MediaProbe probe = new(_runner, log);
string previewInput = settings.InputFiles[0];
double duration = 0;
try
{
duration = await probe.ProbeDurationAsync(tools, previewInput, token);
}
catch (Exception exception)
{
log($"WARNING: Could not probe source duration: {exception.Message}");
}
double seek = PreviewSeekSeconds(settings.TrimStart, duration);
log($"Preview opening frame at {F(seek)}s...");
await _runner.RunAsync(
tools.Ffmpeg,
[
"-hide_banner", "-y", "-ss", F(seek), "-i", previewInput,
"-frames:v", "1", "-update", "1", "-vf", joinFilter, openingPath,
],
log,
null,
token);
string workDir = Path.Combine(outputDirectory, "meta");
Directory.CreateDirectory(workDir);
string endCardPath = await ResolveEndCardPathAsync(settings, workDir, token);
log("Preview end-card frame...");
await _runner.RunAsync(
tools.Ffmpeg,
[
"-hide_banner", "-y", "-loop", "1", "-i", endCardPath,
"-frames:v", "1", "-update", "1",
"-vf", $"scale={settings.Width}:{settings.Height}:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
closingPath,
],
log,
null,
token);
return new VideoPreviewFrames(openingPath, closingPath);
}
private async Task<string> SelectEncoderAsync(ToolPaths tools, EncoderMode requested, Action<string> log, CancellationToken token)
{
if (requested == EncoderMode.CpuX264) return "x264";
bool nvenc;
try
{
await _runner.RunAsync(tools.Ffmpeg,
["-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=640x360:r=1",
"-frames:v", "1", "-an", "-c:v", "h264_nvenc", "-preset", "p6", "-f", "null", "-"],
null, null, token);
nvenc = true;
}
catch (InvalidOperationException)
{
nvenc = false;
}
if (nvenc) return "nvenc";
if (requested == EncoderMode.NvidiaNvenc)
log("WARNING: NVIDIA NVENC was requested but could not initialize. Falling back to CPU libx264.");
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);
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",
"-temporal-aq", "1", "-rc-lookahead", "32", "-bf", "3", "-profile:v", "high", "-pix_fmt", "yuv420p"]
: ["-c:v", "libx264", "-preset", "slow", "-crf", "13", "-profile:v", "high", "-pix_fmt", "yuv420p"];
private async Task TryThumbnailAsync(string ffmpeg, string input, double seek, string output,
RenderSettings s, bool png, Action<string> log, CancellationToken token)
{
string filter = png
? $"scale={s.ThumbnailWidth}:{s.ThumbnailHeight}:force_original_aspect_ratio=decrease,pad={s.ThumbnailWidth}:{s.ThumbnailHeight}:(ow-iw)/2:(oh-ih)/2,format=rgb24"
: $"scale={s.ThumbnailWidth}:{s.ThumbnailHeight}:force_original_aspect_ratio=decrease:out_range=full,pad={s.ThumbnailWidth}:{s.ThumbnailHeight}:(ow-iw)/2:(oh-ih)/2:color=black,format=yuvj420p";
List<string> args = ["-y", "-ss", F(seek), "-i", input, "-frames:v", "1", "-vf", filter];
if (!png) args.AddRange(["-c:v", "mjpeg", "-q:v", "2", "-threads:v", "1", "-strict", "unofficial"]);
args.Add(output);
await _runner.RunAsync(ffmpeg, args, log, null, token, allowFailure: true);
if (!File.Exists(output) || new FileInfo(output).Length == 0)
{
if (File.Exists(output)) File.Delete(output);
log($"WARNING: No frame at {seek:0.###}s; skipped {Path.GetExtension(output)} thumbnail.");
}
}
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, string stageName, double from, double to, double? duration, int framesPerSecond, CancellationToken token)
{
await _runner.RunAsync(executable, args, log, null, token, outputHandler: output =>
{
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, framesPerSecond);
progress.Report(new(percent, stageName, message));
});
}
internal static string BuildProgressMessage(TimeSpan current, double durationSeconds, double? fps, double? speed, int? frame, int framesPerSecond)
{
TimeSpan total = TimeSpan.FromSeconds(durationSeconds);
List<string> parts = [$"{current:hh\\:mm\\:ss} / {total:hh\\:mm\\:ss}"];
int totalFrames = Math.Max(1, (int)Math.Ceiling(durationSeconds * framesPerSecond));
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} / {totalFrames:N0}");
return string.Join(" · ", parts);
}
private static string BuildFinalFilter(RenderSettings s, double trimmed, double fadeStart, double finalDuration, bool hasAudio)
{
string common = $"[0:v]fps={s.FramesPerSecond},scale={s.Width}:{s.Height}:force_original_aspect_ratio=decrease,pad={s.Width}:{s.Height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=rgba,setpts=PTS-STARTPTS[base];" +
$"[1:v]fps={s.FramesPerSecond},scale={s.Width}:{s.Height}:force_original_aspect_ratio=decrease,pad={s.Width}:{s.Height}:(ow-iw)/2:(oh-ih)/2,setsar=1,format=rgba,split=2[cardfade][cardhold];" +
$"[cardfade]trim=duration={F(trimmed)},setpts=PTS-STARTPTS,fade=t=in:st={F(fadeStart)}:d={F(s.FadeSeconds)}:alpha=1[cardfade2];" +
"[base][cardfade2]overlay=0:0:shortest=1,format=yuv420p[vmain];" +
$"[cardhold]trim=duration={F(s.EndCardHoldSeconds)},setpts=PTS-STARTPTS,format=yuv420p[vhold];" +
"[vmain][vhold]concat=n=2:v=1:a=0[v];";
return common + (hasAudio
? $"[0:a]afade=t=out:st={F(fadeStart)}:d={F(s.FadeSeconds)},apad=pad_dur={F(s.EndCardHoldSeconds)},atrim=duration={F(finalDuration)}[a]"
: $"[2:a]atrim=duration={F(finalDuration)}[a]");
}
private static async Task<string> ResolveEndCardPathAsync(RenderSettings settings, string workDir, CancellationToken token)
{
if (!string.IsNullOrWhiteSpace(settings.EndCardPath))
return settings.EndCardPath;
string fallbackPath = Path.Combine(workDir, "default-endcard.ppm");
await File.WriteAllTextAsync(fallbackPath, "P3\n1 1\n255\n0 0 0\n", Encoding.ASCII, token);
return fallbackPath;
}
internal static IReadOnlyList<string> MoveSourceVideos(
IReadOnlyList<string> sources,
string outputDirectory,
Action<string> log)
{
string originalsDir = Path.Combine(outputDirectory, "originals");
Directory.CreateDirectory(originalsDir);
List<string> resolved = new(sources.Count);
HashSet<string> claimed = new(StringComparer.OrdinalIgnoreCase);
foreach (string source in sources)
{
if (!File.Exists(source))
{
log($"WARNING: Source file no longer exists, skipped move: {source}");
resolved.Add(source);
continue;
}
string sourceFull = Path.GetFullPath(source);
string preferred = Path.GetFullPath(Path.Combine(originalsDir, Path.GetFileName(source)));
if (string.Equals(sourceFull, preferred, StringComparison.OrdinalIgnoreCase))
{
claimed.Add(preferred);
resolved.Add(preferred);
continue;
}
string dest = UniqueDestination(preferred, claimed);
try
{
File.Move(source, dest);
claimed.Add(dest);
log($"Moved source to {dest}");
resolved.Add(dest);
}
catch (Exception exception)
{
log($"WARNING: Could not move source '{source}' to originals: {exception.Message}");
resolved.Add(source);
}
}
return resolved;
}
private static string UniqueDestination(string dest, HashSet<string> claimed)
{
string full = Path.GetFullPath(dest);
if (!File.Exists(full) && !claimed.Contains(full))
return full;
string directory = Path.GetDirectoryName(full)!;
string name = Path.GetFileNameWithoutExtension(full);
string extension = Path.GetExtension(full);
for (int index = 2; ; index++)
{
string candidate = Path.Combine(directory, $"{name}_{index}{extension}");
if (!File.Exists(candidate) && !claimed.Contains(candidate))
return candidate;
}
}
internal static void Validate(RenderSettings s)
{
ValidatePreview(s);
if (string.IsNullOrWhiteSpace(s.OutputDirectory)) throw new ArgumentException("Select an output directory.");
if (string.IsNullOrWhiteSpace(s.OutputName) || s.OutputName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("Enter a valid output name.");
if (s.ThumbnailInterval < 1) throw new ArgumentException("Thumbnail interval must be at least one second.");
}
internal static void ValidatePreview(RenderSettings s)
{
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one video input file.");
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more input files no longer exist.");
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
throw new FileNotFoundException("End-card image was not found.");
if (s.CropLeft < 0 || s.CropRight < 0 || s.CropTop < 0 || s.CropBottom < 0)
throw new ArgumentException("Manual crop values must be non-negative.");
}
internal static string BuildJoinVideoFilter(RenderSettings settings, string cropFilter) =>
SourceCropFilters.Prefix(
cropFilter,
$"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p");
internal static string BuildSourceCropFilter(RenderSettings settings, string? autoDetected) =>
SourceCropFilters.Resolve(
settings.SourceCropMode,
settings.CropLeft,
settings.CropRight,
settings.CropTop,
settings.CropBottom,
autoDetected);
internal static double PreviewSeekSeconds(double trimStart, double durationSeconds)
{
double start = Math.Max(0, trimStart);
if (durationSeconds <= start)
return start;
double remaining = durationSeconds - start;
return start + Math.Min(1.2, Math.Max(0, remaining * 0.2));
}
internal static string FormatTimestamp(double seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
private async Task<string> ResolveSourceCropFilterAsync(
ToolPaths tools, RenderSettings settings, Action<string> log, CancellationToken token)
{
string? detected = null;
if (!SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom)
&& settings.SourceCropMode == ShortSourceCropMode.Auto)
{
detected = await DetectHorizontalCropAsync(tools, settings, log, token);
if (detected is null)
log("Warning: could not auto-detect left/right source black borders; using centered 4:3 crop.");
}
else if (SourceCropFilters.HasManualCrop(settings.CropLeft, settings.CropRight, settings.CropTop, settings.CropBottom))
{
log($"Source crop: manual L={settings.CropLeft} R={settings.CropRight} T={settings.CropTop} B={settings.CropBottom}");
}
else if (settings.SourceCropMode == ShortSourceCropMode.None)
{
log("Source crop: none");
}
else
{
log("Source crop: centered 4:3");
}
return BuildSourceCropFilter(settings, detected);
}
private async Task<string?> DetectHorizontalCropAsync(
ToolPaths tools, RenderSettings settings, Action<string> log, CancellationToken token)
{
string input = settings.InputFiles[0];
List<string> args = SourceCropFilters.BuildHorizontalCropDetectArguments(
input, FormatTimestamp(settings.TrimStart), 4);
string output = await _runner.RunAsync(tools.Ffmpeg, args, log, null, token, allowFailure: true);
string? filter = SourceCropFilters.TryBuildAutoHorizontalFilter(output);
if (filter is null)
return null;
log($"Source crop: {filter} (auto left/right black crop)");
return filter;
}
private static string EscapeConcat(string path) => path.Replace("'", "'\\''");
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
}