603af17e56
The repo carried two parallel UIs (WPF + WinUI) sharing Models/Services via
cross-directory Link includes. Now that WinUI is the only frontend, collapse
the structure so the WinUI project IS the repo root instead of a nested
sibling folder:
- Delete the WPF project entirely (App.xaml, MainWindow.xaml, csproj) and its
bin/obj output
- Move AmiReel.WinUI/* up to the repo root (App, MainWindow, MainPage,
DialogHelper, Assets, Package.appxmanifest, app.manifest, Properties,
.github/instructions, AGENTS.md) via git mv, preserving history
- Rename AmiReel.WinUI.csproj -> AmiReel.csproj; regenerate the solution as
AmiReel.slnx (the newer XML solution format) with a single project
- Rename namespace AmigaDB.VideoRenderer.{Models,Services} -> AmiReel.{...}
and AmiReel_WinUI -> AmiReel across all files, including the embedded
ffmpeg/ffprobe resource logical names in the csproj and ToolExtractor
- Models/ and Services/ no longer need the Link-based cross-directory
<Compile Include>; they're picked up by the SDK's default globbing now
that they live under the project directory
- Rename assets/ -> branding/ (source icon art) to avoid a case-insensitive
collision with Assets/ (packaged tile art) once both sit at repo root
- Merge the two .gitignore files into one; track the PublishProfiles pubxml
files instead of ignoring them (no secrets, and they keep publish
reproducible across machines) as branding, gitignore, etc.
- Simplify publish-win-x64.ps1 (drop the -Target Wpf/WinUI switch, there's
only one target now) and rewrite README.md to describe the single-project
layout, build/run/publish commands, and file structure
Verified: dotnet build succeeds for both AmiReel.csproj and AmiReel.slnx, and
the built exe launches and renders identically to before the move.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
239 lines
9.6 KiB
C#
239 lines
9.6 KiB
C#
using System.Globalization;
|
|
using System.IO;
|
|
using System.Runtime.InteropServices;
|
|
using System.Text;
|
|
using System.Text.RegularExpressions;
|
|
|
|
namespace AmiReel.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);
|
|
}
|