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