Implement positioned movie playback opcode
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)}"
|
||||
: "") + ")");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user