Implement positioned movie playback opcode

This commit is contained in:
gamer147
2026-07-29 15:41:01 -04:00
parent 71ae1b7c5b
commit 12af952b77
18 changed files with 523 additions and 46 deletions

View File

@@ -88,6 +88,7 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
private readonly Queue<MovieAudioChunk> _audioChunks = new();
private readonly AutoResetEvent _audioSpace = new(false);
private readonly int _maximumQueuedAudioFrames;
private readonly long _initialPositionMs;
private RgbaImage? _latestFrame;
private volatile bool _completed;
private volatile bool _videoTimelineCompleted;
@@ -101,6 +102,7 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
private long _firstFramePresentationTimeMs = -1;
public long? StopTimeMs => _source.Info.StopTimeMs;
public long InitialPositionMs => _initialPositionMs;
public bool IsCompleted => _completed;
public string? Failure => Volatile.Read(ref _failure);
public long? FirstFramePresentationTimeMs
@@ -114,12 +116,27 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
public MovieAudioInfo? AudioInfo { get; }
public bool AudioDecodingCompleted => _audioDecodingCompleted;
public FfmpegMovieDecoder(MoviePayload movie)
: this(new FfmpegMovieSession(movie), null) { }
public FfmpegMovieDecoder(MoviePayload movie, long initialPositionMs = 0)
: this(new FfmpegMovieSession(movie), null, initialPositionMs) { }
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock? clock)
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock? clock,
long initialPositionMs = 0)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_initialPositionMs = Math.Clamp(
initialPositionMs,
0,
Math.Max(0, source.Info.StopTimeMs - 1));
try
{
if (_initialPositionMs > 0)
source.Seek(_initialPositionMs);
}
catch
{
source.Dispose();
throw;
}
_clock = clock ?? (source.Info.HasAudio
? new ExternallyAdvancedMoviePacingClock()
: new StopwatchMoviePacingClock());
@@ -216,9 +233,24 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
long lastTimestamp = -1;
long decodedFrames = 0;
long firstTimestamp = -1;
FfmpegVideoFrame? selectedSeekFrame = null;
FfmpegVideoFrame? pendingAfterSeek = null;
if (_initialPositionMs > 0)
selectedSeekFrame = SelectFrameAtInitialPosition(out pendingAfterSeek);
while (!_cancel.WaitOne(0))
{
if (!_source.TryDecodeNextVideoFrame(out var frame))
FfmpegVideoFrame frame;
if (selectedSeekFrame != null)
{
frame = selectedSeekFrame;
selectedSeekFrame = null;
}
else if (pendingAfterSeek != null)
{
frame = pendingAfterSeek;
pendingAfterSeek = null;
}
else if (!_source.TryDecodeNextVideoFrame(out frame))
{
if (decodedFrames == 0)
throw new InvalidDataException("FFmpeg stream ended before producing a video frame");
@@ -265,11 +297,37 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
}
}
private FfmpegVideoFrame? SelectFrameAtInitialPosition(out FfmpegVideoFrame? pending)
{
pending = null;
FfmpegVideoFrame? candidate = null;
long priorTimestamp = -1;
while (!_cancel.WaitOne(0) && _source.TryDecodeNextVideoFrame(out FfmpegVideoFrame frame))
{
if (frame.PresentationTimeMs < 0 || frame.PresentationTimeMs < priorTimestamp)
throw new InvalidDataException(
$"FFmpeg returned non-monotonic video timestamp {frame.PresentationTimeMs} " +
$"after {priorTimestamp} during seek preroll");
priorTimestamp = frame.PresentationTimeMs;
if (frame.PresentationTimeMs <= _initialPositionMs)
{
candidate = frame;
continue;
}
if (candidate == null)
return frame;
pending = frame;
return candidate;
}
return candidate;
}
private void AudioDecodeThread()
{
try
{
long priorTimestamp = -1;
long priorSourceTimestamp = -1;
long priorRebasedTimestamp = -1;
while (!_cancel.WaitOne(0))
{
while (Volatile.Read(ref _queuedAudioFrames) >= _maximumQueuedAudioFrames)
@@ -286,18 +344,23 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
if (decoded.FrameCount <= 0
|| decoded.InterleavedStereo.Length != checked(decoded.FrameCount * 2)
|| decoded.PresentationTimeMs < 0
|| decoded.PresentationTimeMs < priorTimestamp)
|| decoded.PresentationTimeMs < priorSourceTimestamp)
throw new InvalidDataException(
$"FFmpeg returned invalid audio block {decoded.FrameCount}f " +
$"at {decoded.PresentationTimeMs} ms after {priorTimestamp} ms");
var chunk = new MovieAudioChunk(decoded.InterleavedStereo, decoded.FrameCount,
decoded.PresentationTimeMs);
$"at {decoded.PresentationTimeMs} ms after {priorSourceTimestamp} ms");
priorSourceTimestamp = decoded.PresentationTimeMs;
MovieAudioChunk? chunk = RebaseAudioChunk(decoded);
if (chunk == null) continue;
if (chunk.PresentationTimeMs < priorRebasedTimestamp)
throw new InvalidDataException(
$"FFmpeg seek produced non-monotonic rebased audio timestamp " +
$"{chunk.PresentationTimeMs} after {priorRebasedTimestamp} ms");
lock (_audioLock)
{
_audioChunks.Enqueue(chunk);
_queuedAudioFrames += chunk.FrameCount;
}
priorTimestamp = decoded.PresentationTimeMs;
priorRebasedTimestamp = chunk.PresentationTimeMs;
}
}
catch (Exception error)
@@ -310,6 +373,32 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
}
}
private MovieAudioChunk? RebaseAudioChunk(FfmpegAudioChunk decoded)
{
int trimFrames = 0;
if (_initialPositionMs > decoded.PresentationTimeMs)
{
long deltaMs = _initialPositionMs - decoded.PresentationTimeMs;
long required = checked(
(deltaMs * (long)_source.Info.AudioSampleRate + 999) / 1000);
trimFrames = checked((int)Math.Min(decoded.FrameCount, required));
}
if (trimFrames >= decoded.FrameCount) return null;
float[] samples = decoded.InterleavedStereo;
int frameCount = decoded.FrameCount - trimFrames;
if (trimFrames > 0)
{
var trimmed = new float[checked(frameCount * 2)];
Array.Copy(samples, checked(trimFrames * 2), trimmed, 0, trimmed.Length);
samples = trimmed;
}
long trimmedTimestamp = decoded.PresentationTimeMs
+ trimFrames * 1000L / _source.Info.AudioSampleRate;
long rebasedTimestamp = Math.Max(0, trimmedTimestamp - _initialPositionMs);
return new MovieAudioChunk(samples, frameCount, rebasedTimestamp);
}
private void Fail(Exception error)
{
Interlocked.CompareExchange(ref _failure, error.Message, null);
@@ -365,5 +454,6 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
internal sealed class FfmpegMovieDecoderFactory : IMovieDecoderFactory
{
public IMovieDecoder Open(MoviePayload movie) => new FfmpegMovieDecoder(movie);
public IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0)
=> new FfmpegMovieDecoder(movie, initialPositionMs);
}

View File

@@ -21,6 +21,11 @@ internal sealed record FfmpegAudioChunk(float[] InterleavedStereo, int FrameCoun
internal interface IFfmpegFrameSource : IDisposable
{
FfmpegMovieInfo Info { get; }
void Seek(long positionMs)
{
if (positionMs != 0)
throw new NotSupportedException("movie source does not support positioned playback");
}
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
bool TryDecodeNextAudioChunk(out FfmpegAudioChunk chunk)
{
@@ -39,7 +44,7 @@ internal sealed class FfmpegMovieSession : IFfmpegFrameSource
public FfmpegMovieSession(MoviePayload movie)
{
ArgumentNullException.ThrowIfNull(movie);
if (FfmpegMovieNative.AbiVersion() != 2)
if (FfmpegMovieNative.AbiVersion() != 3)
throw new InvalidOperationException("unsupported age_movie_ffmpeg ABI version");
byte[] error = new byte[1024];
@@ -70,6 +75,20 @@ internal sealed class FfmpegMovieSession : IFfmpegFrameSource
}
}
public void Seek(long positionMs)
{
ObjectDisposedException.ThrowIf(_handle.IsClosed, this);
int result;
string? error = null;
lock (_decodeLock)
{
result = FfmpegMovieNative.Seek(_handle, Math.Max(0, positionMs));
if (result != 0) error = FfmpegMovieNative.LastError(_handle);
}
if (result != 0)
throw new InvalidDataException($"FFmpeg movie seek failed: {error}");
}
public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame)
{
ObjectDisposedException.ThrowIf(_handle.IsClosed, this);
@@ -240,6 +259,11 @@ internal static class FfmpegMovieNative
nuint rgbaSize,
out long presentationTimeMs);
[DllImport(LibraryName, EntryPoint = "age_movie_seek", CallingConvention = CallingConvention.Cdecl)]
internal static extern int Seek(
FfmpegMovieHandle movie,
long positionMs);
[DllImport(LibraryName, EntryPoint = "age_movie_decode_audio", CallingConvention = CallingConvention.Cdecl)]
internal static extern int DecodeAudio(
FfmpegMovieHandle movie,

View File

@@ -1273,7 +1273,18 @@ public sealed class GodotAdvHost : IHost
var asset = _res.ResolveMovie(resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
out long? stopTimeMs, out _);
initialPositionMs: 0, out long? stopTimeMs, out _);
return stopTimeMs ?? 0;
}
public long? PlayMovieToSurfaceAtPosition(
long resourceId, int surfaceSlot, long movieFlags, long syncMask, long positionMs)
{
string scene = CurrentScene;
var asset = _res.ResolveMovie(resourceId);
if (asset == null) { Godot.GD.Print($"movie unresolved {scene}:0x{resourceId:x}"); return null; }
StartMovie(asset, resourceId, surfaceSlot, movieFlags, syncMask, modal: false,
initialPositionMs: positionMs, out long? stopTimeMs, out _);
return stopTimeMs ?? 0;
}
@@ -1316,6 +1327,7 @@ public sealed class GodotAdvHost : IHost
try
{
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
initialPositionMs: 0,
out _, out long playbackId)) return;
_timeline?.State("modal-movie-wait", new()
{
@@ -1349,7 +1361,8 @@ public sealed class GodotAdvHost : IHost
}
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
long syncMask, bool modal, out long? stopTimeMs, out long playbackId)
long syncMask, bool modal, long initialPositionMs,
out long? stopTimeMs, out long playbackId)
{
stopTimeMs = null;
// A playback is a surface-owned instance, not the shared resource id. BTL can schedule the same
@@ -1372,10 +1385,11 @@ public sealed class GodotAdvHost : IHost
["resource"] = resourceId, ["playback"] = playbackId,
["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
["initial_position_ms"] = Math.Max(0, initialPositionMs),
});
bool started = _main.TryPlayMovie(
movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId, movieFlags,
out stopTimeMs);
initialPositionMs, out stopTimeMs);
if (!started)
{
stopTimeMs = 0;

View File

@@ -11,6 +11,7 @@ internal sealed record MovieAudioChunk(float[] InterleavedStereo, int FrameCount
internal interface IMovieDecoder : IDisposable
{
long? StopTimeMs { get; }
long InitialPositionMs => 0;
bool IsCompleted { get; }
string? Failure { get; }
long? FirstFramePresentationTimeMs => null;
@@ -28,5 +29,5 @@ internal interface IMovieDecoder : IDisposable
internal interface IMovieDecoderFactory
{
IMovieDecoder Open(MoviePayload movie);
IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0);
}

View File

@@ -738,6 +738,7 @@ public partial class Main : Godot.Control
name = pair.Value.Name,
asset_id = pair.Value.AssetId,
stop_time_ms = pair.Value.Decoder.StopTimeMs,
initial_position_ms = pair.Value.InitialPositionMs,
decoder_completed = pair.Value.Decoder.IsCompleted,
decoder_failure = pair.Value.Decoder.Failure,
first_frame_source_pts_ms = pair.Value.Decoder.FirstFramePresentationTimeMs,
@@ -764,6 +765,7 @@ public partial class Main : Godot.Control
name = pair.Value.Name,
asset_id = pair.Value.AssetId,
stop_time_ms = pair.Value.Decoder.StopTimeMs,
initial_position_ms = pair.Value.InitialPositionMs,
decoder_completed = pair.Value.Decoder.IsCompleted,
decoder_failure = pair.Value.Decoder.Failure,
first_frame_source_pts_ms = pair.Value.Decoder.FirstFramePresentationTimeMs,
@@ -2127,6 +2129,7 @@ public partial class Main : Godot.Control
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId, long movieFlags,
long initialPositionMs,
out long? stopTimeMs)
{
stopTimeMs = null;
@@ -2134,7 +2137,8 @@ public partial class Main : Godot.Control
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open(
assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags);
assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags,
initialPositionMs);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
@@ -2164,6 +2168,7 @@ public partial class Main : Godot.Control
_movieCompletionNotified.Remove(playbackId);
GD.Print($"movie started {movie.Name} playback={playbackId} " +
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS" +
(movie.InitialPositionMs > 0 ? $", start={movie.InitialPositionMs}ms" : "") +
(movie.Decoder.AudioInfo is { } audio
? $", audio={audio.SampleRate}Hz stereo route={MovieAudioRouteFromFlags(movie.MovieFlags)}"
: "") + ")");

View File

@@ -4,16 +4,22 @@ 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, long ResourceId, IMovieDecoder Decoder,
long MovieFlags, long StartedAtTimestamp, long WatchdogMs)
long MovieFlags, long InitialPositionMs,
long StartedAtTimestamp, long WatchdogMs)
{
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
IMovieDecoderFactory factory, long movieFlags = 0)
IMovieDecoderFactory factory, long movieFlags = 0,
long initialPositionMs = 0)
{
ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload);
return new MovieRuntime(name, assetId, resourceId, decoder, movieFlags, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? Math.Clamp(stopTime + 2000, 5000, 300000)
IMovieDecoder decoder = factory.Open(payload, Math.Max(0, initialPositionMs));
long remainingMs = decoder.StopTimeMs is >= 0 and var stopTime
? Math.Max(0, stopTime - decoder.InitialPositionMs)
: -1;
return new MovieRuntime(name, assetId, resourceId, decoder, movieFlags,
decoder.InitialPositionMs, Stopwatch.GetTimestamp(),
remainingMs >= 0
? Math.Clamp(remainingMs + 2000, 5000, 300000)
: 30000);
}
}