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> RenderAsync( RenderSettings settings, IProgress progress, Action 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 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 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 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 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 { } } } /// /// Renders two 16:9 stills with the same crop/scale/pad as the final video: a source frame and the end card. /// public async Task RenderPreviewAsync( RenderSettings settings, string outputDirectory, Action 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 SelectEncoderAsync(ToolPaths tools, EncoderMode requested, Action 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 PrepareToolsAsync(ToolPaths tools, Action 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 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 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 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 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 ProbeCombinedDurationAsync(MediaProbe probe, ToolPaths tools, IEnumerable 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 args, Action log, IProgress 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 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 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 MoveSourceVideos( IReadOnlyList sources, string outputDirectory, Action log) { string originalsDir = Path.Combine(outputDirectory, "originals"); Directory.CreateDirectory(originalsDir); List resolved = new(sources.Count); HashSet 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 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 ResolveSourceCropFilterAsync( ToolPaths tools, RenderSettings settings, Action 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 DetectHorizontalCropAsync( ToolPaths tools, RenderSettings settings, Action log, CancellationToken token) { string input = settings.InputFiles[0]; List 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); }