Enable paced FFmpeg movie playback

This commit is contained in:
gamer147
2026-07-22 08:39:27 -04:00
parent 760f84e218
commit 816673cf6a
12 changed files with 365 additions and 22 deletions

View File

@@ -35,6 +35,7 @@ internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB
private IMediaControl? _control;
public bool IsCompleted => _completed;
public string? Failure => _error;
/// <summary>The graph's IMediaPosition stop time converted exactly as native op 0x23f does:
/// seconds * 1000, truncated toward zero. Null means DirectShow supplied no usable value.</summary>
public long? StopTimeMs { get; private set; }

148
godot/FfmpegMovieDecoder.cs Normal file
View File

@@ -0,0 +1,148 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Threading;
using Age.Engine.Sys4;
internal interface IFfmpegFrameSource : IDisposable
{
FfmpegMovieInfo Info { get; }
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
}
internal interface IMoviePacingClock
{
bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation);
}
internal sealed class StopwatchMoviePacingClock : IMoviePacingClock
{
private readonly long _startedAt = Stopwatch.GetTimestamp();
public bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation)
{
while (true)
{
double remaining = elapsedMilliseconds
- Stopwatch.GetElapsedTime(_startedAt).TotalMilliseconds;
if (remaining <= 0) return true;
int waitMilliseconds = (int)Math.Clamp(Math.Ceiling(remaining), 1, 1000);
if (cancellation.WaitOne(waitMilliseconds)) return false;
}
}
}
/// <summary>
/// Timestamp-paced FFmpeg video delivery. The worker decodes no more than one frame ahead, publishes only
/// when its presentation timestamp is due, and reports completion after the final presentation interval.
/// </summary>
internal sealed class FfmpegMovieDecoder : IMovieDecoder
{
private readonly IFfmpegFrameSource _source;
private readonly IMoviePacingClock _clock;
private readonly ManualResetEvent _cancel = new(false);
private readonly Thread _thread;
private readonly object _frameLock = new();
private RgbaImage? _latestFrame;
private volatile bool _completed;
private string? _failure;
private int _disposed;
public long? StopTimeMs => _source.Info.StopTimeMs;
public bool IsCompleted => _completed;
public string? Failure => Volatile.Read(ref _failure);
public FfmpegMovieDecoder(MoviePayload movie)
: this(new FfmpegMovieSession(movie), new StopwatchMoviePacingClock()) { }
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock clock)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_thread = new Thread(DecodeThread)
{
IsBackground = true,
Name = "AGE FFmpeg movie",
};
try { _thread.Start(); }
catch
{
_source.Dispose();
_cancel.Dispose();
throw;
}
}
public bool TryTakeFrame(out RgbaImage frame)
{
lock (_frameLock)
{
if (_latestFrame == null)
{
frame = default!;
return false;
}
frame = _latestFrame;
_latestFrame = null;
return true;
}
}
private void DecodeThread()
{
try
{
long lastTimestamp = -1;
long decodedFrames = 0;
while (!_cancel.WaitOne(0))
{
if (!_source.TryDecodeNextVideoFrame(out var frame))
{
if (decodedFrames == 0)
throw new InvalidDataException("FFmpeg stream ended before producing a video frame");
long completionTime = Math.Max(_source.Info.StopTimeMs,
lastTimestamp + FrameIntervalMilliseconds(_source.Info));
if (_clock.WaitUntil(completionTime, _cancel)) _completed = true;
return;
}
if (frame.PresentationTimeMs < 0 || frame.PresentationTimeMs < lastTimestamp)
throw new InvalidDataException(
$"FFmpeg returned non-monotonic video timestamp {frame.PresentationTimeMs} after {lastTimestamp}");
if (!_clock.WaitUntil(frame.PresentationTimeMs, _cancel)) return;
lock (_frameLock) _latestFrame = frame.Image;
lastTimestamp = frame.PresentationTimeMs;
decodedFrames++;
}
}
catch (Exception error)
{
Volatile.Write(ref _failure, error.Message);
_completed = true; // decode failure must never strand an AGE movie wait
}
finally
{
_source.Dispose();
}
}
private static long FrameIntervalMilliseconds(FfmpegMovieInfo info)
{
if (info.FrameRateNumerator <= 0 || info.FrameRateDenominator <= 0) return 0;
long scaledDenominator = checked((long)info.FrameRateDenominator * 1000);
return Math.Max(1, scaledDenominator / info.FrameRateNumerator);
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_cancel.Set();
if (_thread.IsAlive && Thread.CurrentThread != _thread)
_thread.Join();
_cancel.Dispose();
}
}
internal sealed class FfmpegMovieDecoderFactory : IMovieDecoderFactory
{
public IMovieDecoder Open(MoviePayload movie) => new FfmpegMovieDecoder(movie);
}

View File

@@ -15,7 +15,7 @@ internal readonly record struct FfmpegMovieInfo(
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
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
{
private FfmpegMovieHandle _handle;
public FfmpegMovieInfo Info { get; }

View File

@@ -873,8 +873,8 @@ public sealed class GodotAdvHost : IHost
{
if (_movieFrames.TryGetValue(resId, out var movie))
return (movie.Image, movie.Name, movie.AssetId, true);
// Movie payloads use the same .AGF extension as still images. While DirectShow is opening
// the graph (or before its first sample arrives), keep the already-created surface blank
// Movie payloads use the same .AGF extension as still images. While the decoder is opening
// (or before its first frame arrives), keep the already-created surface blank
// instead of falling through to AgfDecoder and misclassifying the MPEG program stream.
if (_movieBySurface.Values.Contains(resId)) return null;
}
@@ -956,7 +956,7 @@ public sealed class GodotAdvHost : IHost
long syncMask, bool modal, out long? stopTimeMs)
{
stopTimeMs = null;
// Publish the movie identity before the potentially long VFS read and DirectShow graph setup.
// Publish the movie identity before the potentially long VFS read and synchronous decoder setup.
// The compositor can therefore distinguish a legitimate blank pre-roll surface from a still AGF.
ReleaseSurface(surfaceSlot);
lock (_imageLock)

View File

@@ -7,4 +7,12 @@
<ItemGroup>
<ProjectReference Include="..\engine\Age.Engine\Age.Engine.csproj" />
</ItemGroup>
<ItemGroup Condition="Exists('..\build\native\win-x64\age_movie_ffmpeg.dll')">
<None Include="..\build\native\win-x64\*.dll"
Link="%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
<None Include="..\build\native\win-x64\FFmpeg-LICENSE.txt"
Link="FFmpeg-LICENSE.txt"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -10,6 +10,7 @@ internal interface IMovieDecoder : IDisposable
{
long? StopTimeMs { get; }
bool IsCompleted { get; }
string? Failure { get; }
bool TryTakeFrame(out RgbaImage frame);
}

View File

@@ -53,10 +53,10 @@ public partial class Main : Godot.Control
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
private readonly Age.Engine.Hosting.FrameClock _clock = new();
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
// 0x236 creates its graph synchronously on the VM thread so 0x23f can query timing immediately.
// 0x236 opens its decoder 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 IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory();
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
private GodotTraceSink _trace = null!;
@@ -1224,6 +1224,8 @@ public partial class Main : Godot.Control
>= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId))
{
if (movie.Decoder.Failure is { } failure)
GD.Print($"movie decode failed {movie.Name}: {failure}");
if (watchdogExpired && !movie.Decoder.IsCompleted)
GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms");
_host.NotifyMovieCompleted(resourceId);