Enable paced FFmpeg movie playback
This commit is contained in:
@@ -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
148
godot/FfmpegMovieDecoder.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@ internal interface IMovieDecoder : IDisposable
|
||||
{
|
||||
long? StopTimeMs { get; }
|
||||
bool IsCompleted { get; }
|
||||
string? Failure { get; }
|
||||
bool TryTakeFrame(out RgbaImage frame);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user