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(?\d{2}):(?\d{2}):(?\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(avformatHandle, "avformat_open_input"); AvFormatFindStreamInfo = GetDelegate(avformatHandle, "avformat_find_stream_info"); AvFormatCloseInput = GetDelegate(avformatHandle, "avformat_close_input"); AvFindBestStream = GetDelegate(avformatHandle, "av_find_best_stream"); AvDumpFormat = GetDelegate(avformatHandle, "av_dump_format"); AvLogSetCallback = GetDelegate(avutilHandle, "av_log_set_callback"); AvLogSetLevel = GetDelegate(avutilHandle, "av_log_set_level"); AvLogFormatLine2 = GetDelegate(avutilHandle, "av_log_format_line2"); AvStrError = GetDelegate(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(IntPtr handle, string exportName) where T : Delegate => Marshal.GetDelegateForFunctionPointer(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); }