Initial AmiReel application
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using AmigaDB.VideoRenderer.Models;
|
||||
|
||||
namespace AmigaDB.VideoRenderer.Services;
|
||||
|
||||
public static class AppSettingsStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
public static AppSettings Load()
|
||||
{
|
||||
string path = GetSettingsPath();
|
||||
if (!File.Exists(path))
|
||||
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(path);
|
||||
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
|
||||
?? AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
|
||||
}
|
||||
}
|
||||
|
||||
public static void Save(AppSettings settings)
|
||||
{
|
||||
string path = GetSettingsPath();
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
string json = JsonSerializer.Serialize(settings, JsonOptions);
|
||||
File.WriteAllText(path, json);
|
||||
}
|
||||
|
||||
private static string GetSettingsPath() => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"AmiReel", "settings.json");
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.IO;
|
||||
|
||||
namespace AmigaDB.VideoRenderer.Services;
|
||||
|
||||
public sealed partial class ProcessRunner
|
||||
{
|
||||
public async Task<string> RunAsync(
|
||||
string executable,
|
||||
IEnumerable<string> arguments,
|
||||
Action<string>? log,
|
||||
Action<TimeSpan>? position,
|
||||
CancellationToken token,
|
||||
bool allowFailure = false)
|
||||
{
|
||||
ProcessStartInfo start = new()
|
||||
{
|
||||
FileName = executable,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
};
|
||||
foreach (string argument in arguments)
|
||||
start.ArgumentList.Add(argument);
|
||||
|
||||
using Process process = new() { StartInfo = start, EnableRaisingEvents = true };
|
||||
StringBuilder output = new();
|
||||
process.Start();
|
||||
|
||||
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token);
|
||||
Task stderr = PumpAsync(process.StandardError, output, log, position, token);
|
||||
using CancellationTokenRegistration registration = token.Register(() =>
|
||||
{
|
||||
try { if (!process.HasExited) process.Kill(true); } catch { }
|
||||
});
|
||||
|
||||
await Task.WhenAll(stdout, stderr, process.WaitForExitAsync(token));
|
||||
if (process.ExitCode != 0 && !allowFailure)
|
||||
throw new InvalidOperationException($"Process exited with code {process.ExitCode}.\n{output}");
|
||||
return output.ToString();
|
||||
}
|
||||
|
||||
private static async Task PumpAsync(
|
||||
StreamReader reader, StringBuilder output, Action<string>? log,
|
||||
Action<TimeSpan>? position, CancellationToken token)
|
||||
{
|
||||
while (await reader.ReadLineAsync(token) is { } line)
|
||||
{
|
||||
output.AppendLine(line);
|
||||
log?.Invoke(line);
|
||||
Match match = TimeRegex().Match(line);
|
||||
if (match.Success && TimeSpan.TryParse(match.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan time))
|
||||
position?.Invoke(time);
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex TimeRegex();
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using AmigaDB.VideoRenderer.Models;
|
||||
|
||||
namespace AmigaDB.VideoRenderer.Services;
|
||||
|
||||
public sealed class RenderPipeline
|
||||
{
|
||||
private readonly ProcessRunner _runner = new();
|
||||
|
||||
public async Task RenderAsync(
|
||||
RenderSettings settings,
|
||||
IProgress<RenderProgress> progress,
|
||||
Action<string> log,
|
||||
CancellationToken token)
|
||||
{
|
||||
Validate(settings);
|
||||
ToolPaths tools = await ToolExtractor.ResolveAsync(settings.FfmpegPath, settings.FfprobePath, token);
|
||||
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);
|
||||
|
||||
progress.Report(new(2, "Joining recordings", "Creating the 4K 50 FPS intermediate video"));
|
||||
List<string> joinArgs = ["-y", "-f", "concat", "-safe", "0", "-i", concat,
|
||||
"-vf", $"scale=2880:2160:flags=lanczos:force_original_aspect_ratio=decrease,pad={settings.Width}:{settings.Height}:(ow-iw)/2:(oh-ih)/2,format=yuv420p",
|
||||
"-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);
|
||||
|
||||
double originalDuration = await ProbeDurationAsync(tools.Ffprobe, 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 HasAudioAsync(tools.Ffprobe, 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, 60, 99, finalDuration, token);
|
||||
progress.Report(new(100, "Complete", final));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { Directory.Delete(workDir, true); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
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=128x128: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) { log("NVIDIA NVENC selected."); return "nvenc"; }
|
||||
if (requested == EncoderMode.NvidiaNvenc)
|
||||
throw new InvalidOperationException("NVIDIA NVENC was requested but could not initialize.");
|
||||
log("NVENC unavailable; CPU libx264 selected.");
|
||||
return "x264";
|
||||
}
|
||||
|
||||
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 async Task RunStageAsync(string executable, IEnumerable<string> args, Action<string> log,
|
||||
IProgress<RenderProgress> progress, double from, double to, double? duration, CancellationToken token)
|
||||
{
|
||||
await _runner.RunAsync(executable, args, log, time =>
|
||||
{
|
||||
if (duration > 0)
|
||||
progress.Report(new(from + Math.Min(1, time.TotalSeconds / duration.Value) * (to - from), "Rendering", time.ToString(@"hh\:mm\:ss")));
|
||||
}, token);
|
||||
}
|
||||
|
||||
private async Task<double> ProbeDurationAsync(string ffprobe, string input, CancellationToken token)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private static void Validate(RenderSettings s)
|
||||
{
|
||||
if (s.InputFiles.Count == 0) throw new ArgumentException("Select at least one AVI input file.");
|
||||
if (s.InputFiles.Any(f => !File.Exists(f))) throw new FileNotFoundException("One or more AVI files no longer exist.");
|
||||
if (!string.IsNullOrWhiteSpace(s.EndCardPath) && !File.Exists(s.EndCardPath))
|
||||
throw new FileNotFoundException("End-card image was not found.");
|
||||
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.");
|
||||
}
|
||||
|
||||
private static string EscapeConcat(string path) => path.Replace("'", "'\\''");
|
||||
private static string F(double value) => value.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AmigaDB.VideoRenderer.Services;
|
||||
|
||||
public sealed record ToolPaths(string Ffmpeg, string Ffprobe);
|
||||
|
||||
public static class ToolExtractor
|
||||
{
|
||||
public static async Task<ToolPaths> ResolveAsync(string ffmpegPath, string ffprobePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(ffmpegPath) || !string.IsNullOrWhiteSpace(ffprobePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ffmpegPath) || !File.Exists(ffmpegPath))
|
||||
throw new FileNotFoundException("Configured ffmpeg.exe was not found.");
|
||||
if (string.IsNullOrWhiteSpace(ffprobePath) || !File.Exists(ffprobePath))
|
||||
throw new FileNotFoundException("Configured ffprobe.exe was not found.");
|
||||
return new ToolPaths(ffmpegPath, ffprobePath);
|
||||
}
|
||||
|
||||
return await ExtractAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<ToolPaths> ExtractAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
string toolDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"AmiReel", "tools", "0.1.0");
|
||||
Directory.CreateDirectory(toolDir);
|
||||
|
||||
string ffmpeg = await ExtractOneAsync("ffmpeg.exe", toolDir, cancellationToken);
|
||||
string ffprobe = await ExtractOneAsync("ffprobe.exe", toolDir, cancellationToken);
|
||||
return new ToolPaths(ffmpeg, ffprobe);
|
||||
}
|
||||
|
||||
private static async Task<string> ExtractOneAsync(string fileName, string destination, CancellationToken token)
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
string resourceName = $"AmigaDB.VideoRenderer.Tools.{fileName}";
|
||||
await using Stream source = assembly.GetManifestResourceStream(resourceName)
|
||||
?? throw new InvalidOperationException(
|
||||
$"Embedded {fileName} was not found. Add it to ThirdParty and publish the application again.");
|
||||
|
||||
string target = Path.Combine(destination, fileName);
|
||||
string temporary = target + ".new";
|
||||
await using (FileStream output = File.Create(temporary))
|
||||
await source.CopyToAsync(output, token);
|
||||
|
||||
if (File.Exists(target) && FilesMatch(target, temporary))
|
||||
{
|
||||
File.Delete(temporary);
|
||||
return target;
|
||||
}
|
||||
|
||||
File.Move(temporary, target, true);
|
||||
return target;
|
||||
}
|
||||
|
||||
private static bool FilesMatch(string first, string second)
|
||||
{
|
||||
using SHA256 sha = SHA256.Create();
|
||||
using FileStream a = File.OpenRead(first);
|
||||
byte[] aHash = sha.ComputeHash(a);
|
||||
using FileStream b = File.OpenRead(second);
|
||||
byte[] bHash = sha.ComputeHash(b);
|
||||
return aHash.AsSpan().SequenceEqual(bHash);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user