Add isolated FFmpeg movie decoder shim

This commit is contained in:
gamer147
2026-07-22 00:17:27 -04:00
parent f53056818d
commit 760f84e218
16 changed files with 1118 additions and 36 deletions

View File

@@ -18,7 +18,7 @@ internal interface ISampleGrabberCB
/// Decoded RGB32 samples are copied into process memory and consumed by Godot's retained compositor.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB
internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB
{
private readonly byte[] _payload;
private readonly Thread _thread;

162
godot/FfmpegMovieNative.cs Normal file
View File

@@ -0,0 +1,162 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Age.Engine.Sys4;
using Microsoft.Win32.SafeHandles;
internal readonly record struct FfmpegMovieInfo(
int Width,
int Height,
long StopTimeMs,
int FrameRateNumerator,
int FrameRateDenominator,
bool HasAudio);
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
internal sealed class FfmpegMovieSession : IDisposable
{
private FfmpegMovieHandle _handle;
public FfmpegMovieInfo Info { get; }
public FfmpegMovieSession(MoviePayload movie)
{
ArgumentNullException.ThrowIfNull(movie);
if (FfmpegMovieNative.AbiVersion() != 1)
throw new InvalidOperationException("unsupported age_movie_ffmpeg ABI version");
byte[] error = new byte[1024];
int result = FfmpegMovieNative.Open(movie.Bytes, (nuint)movie.Bytes.Length,
out _handle, out var nativeInfo, error, (nuint)error.Length);
if (result != 0 || _handle.IsInvalid)
{
_handle?.Dispose();
throw new InvalidDataException(
$"FFmpeg movie open failed for {movie.Name}: {FfmpegMovieNative.DecodeUtf8(error)}");
}
Info = new FfmpegMovieInfo(nativeInfo.Width, nativeInfo.Height, nativeInfo.StopTimeMs,
nativeInfo.FrameRateNumerator, nativeInfo.FrameRateDenominator, nativeInfo.HasAudio != 0);
if (Info.Width <= 0 || Info.Height <= 0 || Info.StopTimeMs <= 0)
{
_handle.Dispose();
throw new InvalidDataException(
$"FFmpeg returned invalid metadata for {movie.Name}: {Info.Width}x{Info.Height}, {Info.StopTimeMs} ms");
}
}
public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame)
{
ObjectDisposedException.ThrowIf(_handle.IsClosed, this);
byte[] pixels = new byte[checked(Info.Width * Info.Height * 4)];
int result = FfmpegMovieNative.DecodeVideo(_handle, pixels, (nuint)pixels.Length, out long ptsMs);
if (result == FfmpegMovieNative.EndOfFile)
{
frame = default!;
return false;
}
if (result != FfmpegMovieNative.Frame)
throw new InvalidDataException($"FFmpeg movie decode failed: {FfmpegMovieNative.LastError(_handle)}");
frame = new FfmpegVideoFrame(new RgbaImage(Info.Width, Info.Height, pixels), ptsMs);
return true;
}
public void Dispose() => _handle.Dispose();
}
internal sealed class FfmpegMovieHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private FfmpegMovieHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle()
{
FfmpegMovieNative.Close(handle);
return true;
}
}
internal static class FfmpegMovieNative
{
internal const int EndOfFile = 0;
internal const int Frame = 1;
private const string LibraryName = "age_movie_ffmpeg";
[StructLayout(LayoutKind.Sequential)]
internal struct NativeInfo
{
public int Width;
public int Height;
public long StopTimeMs;
public int FrameRateNumerator;
public int FrameRateDenominator;
public int HasAudio;
}
static FfmpegMovieNative()
{
NativeLibrary.SetDllImportResolver(typeof(FfmpegMovieNative).Assembly, ResolveLibrary);
}
private static IntPtr ResolveLibrary(string libraryName, System.Reflection.Assembly assembly,
DllImportSearchPath? searchPath)
{
if (!string.Equals(libraryName, LibraryName, StringComparison.Ordinal)) return IntPtr.Zero;
string fileName = OperatingSystem.IsWindows()
? "age_movie_ffmpeg.dll"
: OperatingSystem.IsMacOS() ? "libage_movie_ffmpeg.dylib" : "libage_movie_ffmpeg.so";
string? configured = Environment.GetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR");
string rid = OperatingSystem.IsWindows() ? "win-x64"
: OperatingSystem.IsMacOS() ? (RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64")
: "linux-x64";
string[] candidates =
{
configured == null ? "" : Path.Combine(configured, fileName),
Path.Combine(AppContext.BaseDirectory, fileName),
Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", fileName),
};
foreach (string candidate in candidates)
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
return NativeLibrary.Load(candidate);
throw new DllNotFoundException(
$"{fileName} was not found; set AGE_FFMPEG_NATIVE_DIR or package runtimes/{rid}/native");
}
internal static string DecodeUtf8(byte[] buffer)
{
int length = Array.IndexOf(buffer, (byte)0);
if (length < 0) length = buffer.Length;
return System.Text.Encoding.UTF8.GetString(buffer, 0, length);
}
internal static string LastError(FfmpegMovieHandle handle)
{
IntPtr text = LastErrorNative(handle);
return text == IntPtr.Zero ? "unknown decoder error" : Marshal.PtrToStringUTF8(text) ?? "unknown decoder error";
}
internal static uint AbiVersion() => AbiVersionNative();
[DllImport(LibraryName, EntryPoint = "age_movie_abi_version", CallingConvention = CallingConvention.Cdecl)]
private static extern uint AbiVersionNative();
[DllImport(LibraryName, EntryPoint = "age_movie_open", CallingConvention = CallingConvention.Cdecl)]
internal static extern int Open(
[In] byte[] bytes,
nuint length,
out FfmpegMovieHandle movie,
out NativeInfo info,
[Out] byte[] errorBuffer,
nuint errorBufferSize);
[DllImport(LibraryName, EntryPoint = "age_movie_decode_video", CallingConvention = CallingConvention.Cdecl)]
internal static extern int DecodeVideo(
FfmpegMovieHandle movie,
[Out] byte[] rgba,
nuint rgbaSize,
out long presentationTimeMs);
[DllImport(LibraryName, EntryPoint = "age_movie_last_error", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr LastErrorNative(FfmpegMovieHandle movie);
[DllImport(LibraryName, EntryPoint = "age_movie_close", CallingConvention = CallingConvention.Cdecl)]
internal static extern void Close(IntPtr movie);
}

25
godot/IMovieDecoder.cs Normal file
View File

@@ -0,0 +1,25 @@
using System;
using System.Runtime.Versioning;
using Age.Engine.Sys4;
/// <summary>
/// Platform-neutral movie playback boundary. Construction is synchronous so metadata needed by the VM is
/// available before op 0x236 returns; frame delivery and completion remain asynchronous.
/// </summary>
internal interface IMovieDecoder : IDisposable
{
long? StopTimeMs { get; }
bool IsCompleted { get; }
bool TryTakeFrame(out RgbaImage frame);
}
internal interface IMovieDecoderFactory
{
IMovieDecoder Open(MoviePayload movie);
}
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoderFactory : IMovieDecoderFactory
{
public IMovieDecoder Open(MoviePayload movie) => new DirectShowMovieDecoder(movie);
}

View File

@@ -56,6 +56,7 @@ public partial class Main : Godot.Control
// 0x236 creates its graph synchronously on the VM thread so 0x23f can query timing immediately.
// Presentation ownership transfers here; _Process adopts staged decoders before sampling frames.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, MovieRuntime> _pendingMovies = new();
private IMovieDecoderFactory _movieDecoderFactory = new DirectShowMovieDecoderFactory();
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
private GodotTraceSink _trace = null!;
@@ -1183,7 +1184,7 @@ public partial class Main : Godot.Control
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = new MovieRuntime(assetName, assetId, new DirectShowMovieDecoder(payload));
var runtime = MovieRuntime.Open(assetName, assetId, payload, _movieDecoderFactory);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(resourceId, runtime))
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
@@ -1242,16 +1243,6 @@ public partial class Main : Godot.Control
_movieCompletionNotified.Remove(resourceId);
}
private sealed record MovieRuntime(string Name, int AssetId, DirectShowMovieDecoder Decoder,
long StartedAtTimestamp, long WatchdogMs)
{
public MovieRuntime(string name, int assetId, DirectShowMovieDecoder decoder)
: this(name, assetId, decoder, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? System.Math.Clamp(stopTime + 2000, 5000, 300000)
: 30000) { }
}
public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak()
{

19
godot/MovieRuntime.cs Normal file
View File

@@ -0,0 +1,19 @@
using System;
using System.Diagnostics;
using Age.Engine.Sys4;
/// <summary>Presentation-side ownership for one decoder plus its fail-safe completion deadline.</summary>
internal sealed record MovieRuntime(string Name, int AssetId, IMovieDecoder Decoder,
long StartedAtTimestamp, long WatchdogMs)
{
public static MovieRuntime Open(string name, int assetId, MoviePayload payload,
IMovieDecoderFactory factory)
{
ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload);
return new MovieRuntime(name, assetId, decoder, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? Math.Clamp(stopTime + 2000, 5000, 300000)
: 30000);
}
}