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
+6 -4
View File
@@ -7,22 +7,24 @@ namespace AmigaDB.VideoRenderer.Services;
public static class AppSettingsStore
{
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
private static string DefaultOutputFolder => Environment.GetFolderPath(Environment.SpecialFolder.MyVideos);
public static AppSettings Load()
{
string path = GetSettingsPath();
if (!File.Exists(path))
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
return AppSettings.Default(DefaultOutputFolder);
try
{
string json = File.ReadAllText(path);
return JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
return (JsonSerializer.Deserialize<AppSettings>(json, JsonOptions)
?? AppSettings.Default(DefaultOutputFolder))
.Normalize(DefaultOutputFolder);
}
catch
{
return AppSettings.Default(Environment.GetFolderPath(Environment.SpecialFolder.MyVideos));
return AppSettings.Default(DefaultOutputFolder);
}
}
+238
View File
@@ -0,0 +1,238 @@
using System.Globalization;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
namespace AmigaDB.VideoRenderer.Services;
internal sealed partial class FfmpegLibraryProbe : IDisposable
{
private const int AvMediaTypeAudio = 1;
private const int AvLogInfo = 32;
private static readonly object ProbeLock = new();
private static readonly AvLogCallback SharedLogCallback = HandleLogMessage;
private static StringBuilder? s_activeLogBuffer;
private static FfmpegBindings? s_activeBindings;
private readonly IntPtr _avformatHandle;
private readonly IntPtr _avutilHandle;
private readonly FfmpegBindings _bindings;
private readonly string _libraryDirectory;
private FfmpegLibraryProbe(string libraryDirectory, IntPtr avformatHandle, IntPtr avutilHandle)
{
_libraryDirectory = libraryDirectory;
_avformatHandle = avformatHandle;
_avutilHandle = avutilHandle;
_bindings = new FfmpegBindings(avformatHandle, avutilHandle);
}
public static bool TryCreate(string executablePath, out FfmpegLibraryProbe? probe)
{
probe = null;
string? directory = Path.GetDirectoryName(executablePath);
if (string.IsNullOrWhiteSpace(directory) || !Directory.Exists(directory))
return false;
string? avformatPath = FindLibrary(directory, "avformat");
string? avutilPath = FindLibrary(directory, "avutil");
if (avformatPath is null || avutilPath is null)
return false;
try
{
IntPtr avformatHandle = NativeLibrary.Load(avformatPath);
IntPtr avutilHandle = NativeLibrary.Load(avutilPath);
probe = new FfmpegLibraryProbe(directory, avformatHandle, avutilHandle);
return true;
}
catch
{
probe?.Dispose();
probe = null;
return false;
}
}
public double ProbeDuration(string inputPath)
{
string dump = ProbeFormatDump(inputPath);
Match match = DurationRegex().Match(dump);
if (!match.Success)
throw new InvalidOperationException("FFmpeg library probing could not determine media duration.");
int hours = int.Parse(match.Groups["hours"].Value, CultureInfo.InvariantCulture);
int minutes = int.Parse(match.Groups["minutes"].Value, CultureInfo.InvariantCulture);
double seconds = double.Parse(match.Groups["seconds"].Value, CultureInfo.InvariantCulture);
return new TimeSpan(0, hours, minutes, 0).TotalSeconds + seconds;
}
public bool HasAudio(string inputPath)
{
IntPtr context = OpenInput(inputPath);
try
{
int streamIndex = _bindings.AvFindBestStream(context, AvMediaTypeAudio, -1, -1, IntPtr.Zero, 0);
return streamIndex >= 0;
}
finally
{
_bindings.AvFormatCloseInput(ref context);
}
}
public void Dispose()
{
if (_avformatHandle != IntPtr.Zero) NativeLibrary.Free(_avformatHandle);
if (_avutilHandle != IntPtr.Zero) NativeLibrary.Free(_avutilHandle);
}
private string ProbeFormatDump(string inputPath)
{
IntPtr context = OpenInput(inputPath);
try
{
lock (ProbeLock)
{
StringBuilder buffer = new();
s_activeBindings = _bindings;
s_activeLogBuffer = buffer;
_bindings.AvLogSetLevel(AvLogInfo);
_bindings.AvLogSetCallback(SharedLogCallback);
_bindings.AvDumpFormat(context, 0, inputPath, 0);
s_activeLogBuffer = null;
s_activeBindings = null;
return buffer.ToString();
}
}
finally
{
_bindings.AvFormatCloseInput(ref context);
}
}
private IntPtr OpenInput(string inputPath)
{
IntPtr context = IntPtr.Zero;
int openResult = _bindings.AvFormatOpenInput(ref context, inputPath, IntPtr.Zero, IntPtr.Zero);
if (openResult < 0)
throw new InvalidOperationException($"FFmpeg library probing failed to open '{inputPath}': {FormatError(openResult)}");
int infoResult = _bindings.AvFormatFindStreamInfo(context, IntPtr.Zero);
if (infoResult < 0)
{
_bindings.AvFormatCloseInput(ref context);
throw new InvalidOperationException($"FFmpeg library probing failed to read stream info for '{inputPath}': {FormatError(infoResult)}");
}
return context;
}
private string FormatError(int errorCode)
{
IntPtr buffer = Marshal.AllocHGlobal(1024);
try
{
int result = _bindings.AvStrError(errorCode, buffer, (UIntPtr)1024);
if (result < 0)
return $"error {errorCode}";
return Marshal.PtrToStringUTF8(buffer) ?? $"error {errorCode}";
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
private static string? FindLibrary(string directory, string prefix) =>
Directory.EnumerateFiles(directory, $"{prefix}*.dll", SearchOption.TopDirectoryOnly)
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
private static void HandleLogMessage(IntPtr avcl, int level, IntPtr format, IntPtr args)
{
StringBuilder? buffer = s_activeLogBuffer;
FfmpegBindings? bindings = s_activeBindings;
if (buffer is null || bindings is null)
return;
IntPtr lineBuffer = Marshal.AllocHGlobal(4096);
try
{
Marshal.Copy(new byte[4096], 0, lineBuffer, 4096);
int printPrefix = 1;
bindings.AvLogFormatLine2(avcl, level, format, args, lineBuffer, 4096, ref printPrefix);
string? line = Marshal.PtrToStringUTF8(lineBuffer);
if (!string.IsNullOrWhiteSpace(line))
buffer.Append(line);
}
finally
{
Marshal.FreeHGlobal(lineBuffer);
}
}
[GeneratedRegex(@"Duration:\s(?<hours>\d{2}):(?<minutes>\d{2}):(?<seconds>\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex DurationRegex();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvLogCallback(IntPtr avcl, int level, IntPtr format, IntPtr args);
private sealed class FfmpegBindings
{
public FfmpegBindings(IntPtr avformatHandle, IntPtr avutilHandle)
{
AvFormatOpenInput = GetDelegate<AvFormatOpenInputDelegate>(avformatHandle, "avformat_open_input");
AvFormatFindStreamInfo = GetDelegate<AvFormatFindStreamInfoDelegate>(avformatHandle, "avformat_find_stream_info");
AvFormatCloseInput = GetDelegate<AvFormatCloseInputDelegate>(avformatHandle, "avformat_close_input");
AvFindBestStream = GetDelegate<AvFindBestStreamDelegate>(avformatHandle, "av_find_best_stream");
AvDumpFormat = GetDelegate<AvDumpFormatDelegate>(avformatHandle, "av_dump_format");
AvLogSetCallback = GetDelegate<AvLogSetCallbackDelegate>(avutilHandle, "av_log_set_callback");
AvLogSetLevel = GetDelegate<AvLogSetLevelDelegate>(avutilHandle, "av_log_set_level");
AvLogFormatLine2 = GetDelegate<AvLogFormatLine2Delegate>(avutilHandle, "av_log_format_line2");
AvStrError = GetDelegate<AvStrErrorDelegate>(avutilHandle, "av_strerror");
}
public AvFormatOpenInputDelegate AvFormatOpenInput { get; }
public AvFormatFindStreamInfoDelegate AvFormatFindStreamInfo { get; }
public AvFormatCloseInputDelegate AvFormatCloseInput { get; }
public AvFindBestStreamDelegate AvFindBestStream { get; }
public AvDumpFormatDelegate AvDumpFormat { get; }
public AvLogSetCallbackDelegate AvLogSetCallback { get; }
public AvLogSetLevelDelegate AvLogSetLevel { get; }
public AvLogFormatLine2Delegate AvLogFormatLine2 { get; }
public AvStrErrorDelegate AvStrError { get; }
private static T GetDelegate<T>(IntPtr handle, string exportName) where T : Delegate =>
Marshal.GetDelegateForFunctionPointer<T>(NativeLibrary.GetExport(handle, exportName));
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvFormatOpenInputDelegate(ref IntPtr context, [MarshalAs(UnmanagedType.LPUTF8Str)] string url, IntPtr format, IntPtr options);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvFormatFindStreamInfoDelegate(IntPtr context, IntPtr options);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvFormatCloseInputDelegate(ref IntPtr context);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvFindBestStreamDelegate(IntPtr context, int mediaType, int wantedStream, int relatedStream, IntPtr decoder, int flags);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvDumpFormatDelegate(IntPtr context, int index, [MarshalAs(UnmanagedType.LPUTF8Str)] string url, int isOutput);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvLogSetCallbackDelegate(AvLogCallback callback);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void AvLogSetLevelDelegate(int level);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvLogFormatLine2Delegate(IntPtr avcl, int level, IntPtr format, IntPtr args, IntPtr line, int lineSize, ref int printPrefix);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate int AvStrErrorDelegate(int errorCode, IntPtr errorBuffer, UIntPtr errorBufferSize);
}
+49
View File
@@ -0,0 +1,49 @@
using System.Globalization;
using System.IO;
namespace AmigaDB.VideoRenderer.Services;
internal sealed class MediaProbe
{
private readonly ProcessRunner _runner;
private readonly Action<string>? _log;
public MediaProbe(ProcessRunner runner, Action<string>? log)
{
_runner = runner;
_log = log;
}
public async Task<double> ProbeDurationAsync(ToolPaths tools, string input, CancellationToken token)
{
using FfmpegLibraryProbe? libraryProbe = CreateLibraryProbe(tools);
if (libraryProbe is not null)
return libraryProbe.ProbeDuration(input);
string output = await _runner.RunAsync(tools.Ffprobe,
["-v", "error", "-show_entries", "format=duration", "-of", "default=nk=1:nw=1", input],
null, null, token);
return double.Parse(output.Trim(), CultureInfo.InvariantCulture);
}
public async Task<bool> HasAudioAsync(ToolPaths tools, string input, CancellationToken token)
{
using FfmpegLibraryProbe? libraryProbe = CreateLibraryProbe(tools);
if (libraryProbe is not null)
return libraryProbe.HasAudio(input);
string output = await _runner.RunAsync(tools.Ffprobe,
["-v", "error", "-select_streams", "a", "-show_entries", "stream=index", "-of", "csv=p=0", input],
null, null, token);
return !string.IsNullOrWhiteSpace(output);
}
private FfmpegLibraryProbe? CreateLibraryProbe(ToolPaths tools)
{
if (!FfmpegLibraryProbe.TryCreate(tools.Ffmpeg, out FfmpegLibraryProbe? probe))
return null;
_log?.Invoke($"Using FFmpeg shared libraries for media probing from '{Path.GetDirectoryName(tools.Ffmpeg)}'.");
return probe;
}
}
+89 -7
View File
@@ -8,13 +8,16 @@ namespace AmigaDB.VideoRenderer.Services;
public sealed partial class ProcessRunner
{
public sealed record ProcessOutput(string Line, TimeSpan? Time, double? FramesPerSecond, double? Speed, int? Frame);
public async Task<string> RunAsync(
string executable,
IEnumerable<string> arguments,
Action<string>? log,
Action<TimeSpan>? position,
CancellationToken token,
bool allowFailure = false)
bool allowFailure = false,
Action<ProcessOutput>? outputHandler = null)
{
ProcessStartInfo start = new()
{
@@ -33,8 +36,8 @@ public sealed partial class ProcessRunner
StringBuilder output = new();
process.Start();
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token);
Task stderr = PumpAsync(process.StandardError, output, log, position, token);
Task stdout = PumpAsync(process.StandardOutput, output, log, position, token, outputHandler);
Task stderr = PumpAsync(process.StandardError, output, log, position, token, outputHandler);
using CancellationTokenRegistration registration = token.Register(() =>
{
try { if (!process.HasExited) process.Kill(true); } catch { }
@@ -48,18 +51,97 @@ public sealed partial class ProcessRunner
private static async Task PumpAsync(
StreamReader reader, StringBuilder output, Action<string>? log,
Action<TimeSpan>? position, CancellationToken token)
Action<TimeSpan>? position, CancellationToken token, Action<ProcessOutput>? outputHandler)
{
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))
ProcessOutput parsed = ParseOutput(line);
if (!IsNoise(parsed))
log?.Invoke(line);
if (parsed.Time is { } time)
position?.Invoke(time);
outputHandler?.Invoke(parsed);
}
}
private static ProcessOutput ParseOutput(string line)
{
TimeSpan? time = null;
double? fps = null;
double? speed = null;
int? frame = null;
Match timeMatch = TimeRegex().Match(line);
if (timeMatch.Success && TimeSpan.TryParse(timeMatch.Groups[1].Value, CultureInfo.InvariantCulture, out TimeSpan parsedTime))
time = parsedTime;
Match frameMatch = FrameRegex().Match(line);
if (frameMatch.Success)
{
if (int.TryParse(frameMatch.Groups["frame"].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsedFrame))
frame = parsedFrame;
if (double.TryParse(frameMatch.Groups["fps"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedFps))
fps = parsedFps;
if (double.TryParse(frameMatch.Groups["speed"].Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsedSpeed))
speed = parsedSpeed;
}
return new ProcessOutput(line, time, fps, speed, frame);
}
private static bool IsNoise(ProcessOutput output)
{
if (output.Frame is not null || output.Time is not null)
return true;
string line = output.Line.TrimStart();
if (string.IsNullOrWhiteSpace(line))
return true;
if (line.StartsWith("ERROR:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("WARNING:", StringComparison.OrdinalIgnoreCase)
|| line.Contains("selected.", StringComparison.OrdinalIgnoreCase)
|| line.Contains("Falling back to FFmpeg from PATH", StringComparison.OrdinalIgnoreCase)
|| line.Contains("skipped", StringComparison.OrdinalIgnoreCase)
|| line.Contains("could not", StringComparison.OrdinalIgnoreCase)
|| line.Contains("failed", StringComparison.OrdinalIgnoreCase)
|| line.Contains("not found", StringComparison.OrdinalIgnoreCase)
|| line.Contains("Output file is empty", StringComparison.OrdinalIgnoreCase))
return false;
return line.StartsWith("ffmpeg version ", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("built with ", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("configuration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavutil", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavcodec", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavformat", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavdevice", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libavfilter", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswscale", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("libswresample", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Input #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Output #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream mapping:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Stream #", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Metadata:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Side data:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("encoder :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("title :", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Press [q] to stop", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("[", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("Duration:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("video:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("audio:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("subtitle:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("other streams:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("global headers:", StringComparison.OrdinalIgnoreCase)
|| line.StartsWith("muxing overhead:", StringComparison.OrdinalIgnoreCase);
}
[GeneratedRegex(@"time=(\d{2}:\d{2}:\d{2}(?:\.\d+)?)", RegexOptions.CultureInvariant)]
private static partial Regex TimeRegex();
[GeneratedRegex(@"frame=\s*(?<frame>\d+).*?fps=\s*(?<fps>\d+(?:\.\d+)?).*?speed=\s*(?<speed>\d+(?:\.\d+)?)x", RegexOptions.CultureInvariant)]
private static partial Regex FrameRegex();
}
+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)
+34
View File
@@ -22,6 +22,18 @@ public static class ToolExtractor
return await ExtractAsync(cancellationToken);
}
public static bool TryResolveFromSystemPath(out ToolPaths? tools)
{
tools = null;
string? ffmpeg = FindOnPath("ffmpeg.exe");
string? ffprobe = FindOnPath("ffprobe.exe");
if (ffmpeg is null || ffprobe is null)
return false;
tools = new ToolPaths(ffmpeg, ffprobe);
return true;
}
private static async Task<ToolPaths> ExtractAsync(CancellationToken cancellationToken = default)
{
string toolDir = Path.Combine(
@@ -66,4 +78,26 @@ public static class ToolExtractor
byte[] bHash = sha.ComputeHash(b);
return aHash.AsSpan().SequenceEqual(bHash);
}
private static string? FindOnPath(string fileName)
{
string? path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(path))
return null;
foreach (string directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
try
{
string candidate = Path.Combine(directory, fileName);
if (File.Exists(candidate))
return candidate;
}
catch
{
}
}
return null;
}
}