Implement movie mask transition opcode

This commit is contained in:
gamer147
2026-07-29 16:01:00 -04:00
parent 12af952b77
commit 3378abdeca
18 changed files with 573 additions and 45 deletions

View File

@@ -89,6 +89,7 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
private readonly AutoResetEvent _audioSpace = new(false);
private readonly int _maximumQueuedAudioFrames;
private readonly long _initialPositionMs;
private readonly long? _presentationDurationMs;
private RgbaImage? _latestFrame;
private volatile bool _completed;
private volatile bool _videoTimelineCompleted;
@@ -116,17 +117,21 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
public MovieAudioInfo? AudioInfo { get; }
public bool AudioDecodingCompleted => _audioDecodingCompleted;
public FfmpegMovieDecoder(MoviePayload movie, long initialPositionMs = 0)
: this(new FfmpegMovieSession(movie), null, initialPositionMs) { }
public FfmpegMovieDecoder(
MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null)
: this(new FfmpegMovieSession(movie), null, initialPositionMs, presentationDurationMs) { }
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock? clock,
long initialPositionMs = 0)
long initialPositionMs = 0, long? presentationDurationMs = null)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_initialPositionMs = Math.Clamp(
initialPositionMs,
0,
Math.Max(0, source.Info.StopTimeMs - 1));
_presentationDurationMs = presentationDurationMs is >= 0
? Math.Max(0, presentationDurationMs.Value)
: null;
try
{
if (_initialPositionMs > 0)
@@ -254,10 +259,11 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
{
if (decodedFrames == 0)
throw new InvalidDataException("FFmpeg stream ended before producing a video frame");
long completionTime = PresentationDeadline(
Math.Max(_source.Info.StopTimeMs,
lastTimestamp + FrameIntervalMilliseconds(_source.Info)),
firstTimestamp);
long completionTime = _presentationDurationMs
?? PresentationDeadline(
Math.Max(_source.Info.StopTimeMs,
lastTimestamp + FrameIntervalMilliseconds(_source.Info)),
firstTimestamp);
if (_clock.WaitUntil(completionTime, _cancel))
{
_videoTimelineCompleted = true;
@@ -432,7 +438,11 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
// stream's mux timestamp origin. DirectShow presents the video's first sample as video time zero;
// retaining the absolute mux offset here would freeze that sample until the audio clock caught up.
// Preserve every decoded frame and its cadence, but rebase the video stream to its first sample.
return Math.Max(0, sourceTimestamp - firstVideoTimestamp);
long sourceElapsed = Math.Max(0, sourceTimestamp - firstVideoTimestamp);
if (_presentationDurationMs is not { } duration
|| _source.Info.StopTimeMs <= 0)
return sourceElapsed;
return checked(sourceElapsed * duration / _source.Info.StopTimeMs);
}
public void Dispose()
@@ -454,6 +464,7 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
internal sealed class FfmpegMovieDecoderFactory : IMovieDecoderFactory
{
public IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0)
=> new FfmpegMovieDecoder(movie, initialPositionMs);
public IMovieDecoder Open(
MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null)
=> new FfmpegMovieDecoder(movie, initialPositionMs, presentationDurationMs);
}

View File

@@ -41,6 +41,11 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, long> _surfaceColorKeys = new();
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
private readonly MovieSurfaceRegistry _movieSurfaces = new();
private sealed record MovieMaskPlayback(
GfxState Gfx, MovieMaskTransitionRequest Request, RgbaImage Captured);
private readonly object _movieMaskLock = new();
private readonly Dictionary<long, MovieMaskPlayback> _movieMasksByPlayback = new();
private readonly Dictionary<int, long> _movieMaskPlaybackBySurface = new();
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the selected game's
// logical canvas, but op 0x1fa releases it like any other slot; subsequent queries return 0x0.
@@ -1256,6 +1261,12 @@ public sealed class GodotAdvHost : IHost
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
int surfaceSlot, long fallbackResourceId)
{
lock (_movieMaskLock)
if (_movieMaskPlaybackBySurface.ContainsKey(surfaceSlot))
lock (_imageLock)
if (_surfaceImages.TryGetValue(surfaceSlot, out var masked))
return (masked, $"<movie-mask:{surfaceSlot}>",
int.MinValue + surfaceSlot, true);
if (_movieSurfaces.TryResolveSurface(surfaceSlot, out var movie) && movie != null)
return (movie.Image, movie.Name, movie.AssetId, true);
if (_movieSurfaces.IsBound(surfaceSlot)) return null;
@@ -1273,7 +1284,8 @@ 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,
initialPositionMs: 0, out long? stopTimeMs, out _);
initialPositionMs: 0, startDelayMs: 0, presentationDurationMs: null,
movieMask: null, out long? stopTimeMs, out _);
return stopTimeMs ?? 0;
}
@@ -1284,10 +1296,40 @@ 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,
initialPositionMs: positionMs, out long? stopTimeMs, out _);
initialPositionMs: positionMs, startDelayMs: 0, presentationDurationMs: null,
movieMask: null, out long? stopTimeMs, out _);
return stopTimeMs ?? 0;
}
public void PlayMovieMaskTransition(GfxState gfx, MovieMaskTransitionRequest request)
{
gfx.QueueMovieMaskTransition(request);
RgbaImage captured = CaptureRetainedRange(
gfx, request.SurfaceSlot, request.SourceRangeStart, request.SourceRangeCount);
byte initialFill = request.Mode == 1 ? (byte)0 : (byte)255;
PublishMaskedCapture(
request.SurfaceSlot, captured,
CreateFilledMask(request.Width, request.Height, initialFill), request);
var asset = _res.ResolveMovie(request.ResourceId);
if (asset == null)
{
Godot.GD.Print($"movie mask unresolved {CurrentScene}:0x{request.ResourceId:x}");
PublishMovieMaskTerminal(new MovieMaskPlayback(gfx, request, captured));
return;
}
var mask = new MovieMaskPlayback(gfx, request, captured);
bool started = StartMovie(
asset, request.ResourceId, request.SurfaceSlot, movieFlags: 6, syncMask: 0,
modal: false, initialPositionMs: 0,
startDelayMs: request.StartDelayMs,
presentationDurationMs: request.DurationMs,
movieMask: mask, out long? stopTimeMs, out _);
gfx.SetMovieStopTime(request.SurfaceSlot, stopTimeMs ?? 0);
if (!started) PublishMovieMaskTerminal(mask);
}
public bool IsMovieSurfaceActive(int surfaceSlot)
=> _movieSurfaces.IsActive(surfaceSlot);
@@ -1327,7 +1369,8 @@ public sealed class GodotAdvHost : IHost
try
{
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
initialPositionMs: 0,
initialPositionMs: 0, startDelayMs: 0,
presentationDurationMs: null, movieMask: null,
out _, out long playbackId)) return;
_timeline?.State("modal-movie-wait", new()
{
@@ -1361,7 +1404,8 @@ public sealed class GodotAdvHost : IHost
}
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
long syncMask, bool modal, long initialPositionMs,
long syncMask, bool modal, long initialPositionMs, long startDelayMs,
long? presentationDurationMs, MovieMaskPlayback? movieMask,
out long? stopTimeMs, out long playbackId)
{
stopTimeMs = null;
@@ -1369,14 +1413,27 @@ public sealed class GodotAdvHost : IHost
// asset on multiple surfaces; replacing one binding must not erase another binding's completion.
MovieSurfaceBinding binding = _movieSurfaces.Begin(surfaceSlot, resourceId, out var replaced);
playbackId = binding.PlaybackId;
if (replaced is { } prior) _main.CallDeferred("StopMovie", prior.PlaybackId);
if (replaced is { } prior)
{
AbandonMovieMaskPlayback(prior.PlaybackId);
ForgetMovieMaskSurface(prior.SurfaceSlot, prior.PlaybackId);
_main.CallDeferred("StopMovie", prior.PlaybackId);
}
if (movieMask != null)
lock (_movieMaskLock)
{
_movieMasksByPlayback[playbackId] = movieMask;
_movieMaskPlaybackBySurface[surfaceSlot] = playbackId;
}
lock (_imageLock)
{
_surfaceImages.Remove(surfaceSlot);
if (movieMask == null) _surfaceImages.Remove(surfaceSlot);
_surfaceColorKeys.Remove(surfaceSlot);
}
// Movie surfaces inherit the selected game's primary size until a decoded frame supplies content.
_slotDims[surfaceSlot] = (_screenWidth, _screenHeight);
_slotDims[surfaceSlot] = movieMask == null
? (_screenWidth, _screenHeight)
: (movieMask.Captured.Width, movieMask.Captured.Height);
try
{
var movie = _res.ReadMovie(asset);
@@ -1386,10 +1443,13 @@ public sealed class GodotAdvHost : IHost
["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
["initial_position_ms"] = Math.Max(0, initialPositionMs),
["start_delay_ms"] = Math.Max(0, startDelayMs),
["presentation_duration_ms"] = presentationDurationMs,
["movie_mask"] = movieMask != null,
});
bool started = _main.TryPlayMovie(
movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId, movieFlags,
initialPositionMs, out stopTimeMs);
initialPositionMs, startDelayMs, presentationDurationMs, out stopTimeMs);
if (!started)
{
stopTimeMs = 0;
@@ -1399,6 +1459,7 @@ public sealed class GodotAdvHost : IHost
}
catch (System.Exception e)
{
AbandonMovieMaskPlayback(playbackId);
_movieSurfaces.Abandon(playbackId, out _);
_slotDims.Remove(surfaceSlot);
stopTimeMs = 0;
@@ -1427,6 +1488,8 @@ public sealed class GodotAdvHost : IHost
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
{
var binding = movieRelease.Binding;
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
@@ -1577,6 +1640,8 @@ public sealed class GodotAdvHost : IHost
}
foreach (MovieSurfaceBinding binding in stoppedMovies)
{
AbandonMovieMaskPlayback(binding.PlaybackId);
ForgetMovieMaskSurface(binding.SurfaceSlot, binding.PlaybackId);
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
@@ -1592,12 +1657,25 @@ public sealed class GodotAdvHost : IHost
// sample callback: the retained object keeps its surface binding while only the surface pixels change.
public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame)
{
MovieMaskPlayback? maskPlayback;
lock (_movieMaskLock) _movieMasksByPlayback.TryGetValue(playbackId, out maskPlayback);
if (maskPlayback != null)
{
var request = maskPlayback.Request;
byte initialFill = request.Mode == 1 ? (byte)0 : (byte)255;
byte[] mask = MovieMaskSurface.ExtractGreen(
frame, request.Width, request.Height, initialFill);
PublishMaskedCapture(request.SurfaceSlot, maskPlayback.Captured, mask, request);
return;
}
if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId))
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
public void NotifyMovieCompleted(long playbackId)
{
MovieMaskPlayback? maskPlayback = RemoveMovieMaskPlayback(playbackId);
if (maskPlayback != null) PublishMovieMaskTerminal(maskPlayback);
if (!_movieSurfaces.Complete(playbackId)) return;
_timeline?.Event("movie-complete", new() { ["playback"] = playbackId });
_frameSignal.Set();
@@ -1606,6 +1684,85 @@ public sealed class GodotAdvHost : IHost
private bool HasActiveMoviePresentation()
=> _movieSurfaces.HasActivePlayback;
private RgbaImage CaptureRetainedRange(
GfxState gfx, int targetSlot, long firstHandle, int count)
{
var dimensions = _slotDims.GetValueOrDefault(
targetSlot, (W: _screenWidth, H: _screenHeight));
int width = System.Math.Max(0, dimensions.W);
int height = System.Math.Max(0, dimensions.H);
var captured = new RgbaImage(width, height, new byte[checked(width * height * 4)]);
IReadOnlyList<RenderObject> visible = gfx.SnapshotVisibleObjects(_clock.NowMs);
RetainedSurfaceRasterizer.CompositeRange(
captured, visible, firstHandle, System.Math.Max(0, count),
item =>
{
var raw = gfx.TryGet(item.Handle);
var resolved = raw != null
? ResolveSurfaceTexture(raw.SourceSlot, item.SurfaceResId)
: ResolveResIdTexture(item.SurfaceResId);
return resolved == null
? null
: RgbaSurfaceOps.WithColorKey(resolved.Value.Image, item.ColorKey);
});
return captured;
}
private static byte[] CreateFilledMask(int width, int height, byte fill)
{
var mask = new byte[checked(System.Math.Max(0, width) * System.Math.Max(0, height))];
if (fill != 0) System.Array.Fill(mask, fill);
return mask;
}
private void PublishMaskedCapture(
int surfaceSlot, RgbaImage captured, byte[] mask, MovieMaskTransitionRequest request)
{
RgbaImage image = MovieMaskSurface.Apply(
captured, mask, System.Math.Max(0, request.Width),
System.Math.Max(0, request.Height), request.X, request.Y);
lock (_imageLock) _surfaceImages[surfaceSlot] = image;
_slotDims[surfaceSlot] = (image.Width, image.Height);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
_frameSignal.Set();
}
private void PublishMovieMaskTerminal(MovieMaskPlayback playback)
{
byte terminalFill = playback.Request.Mode == 1 ? (byte)255 : (byte)0;
PublishMaskedCapture(
playback.Request.SurfaceSlot, playback.Captured,
CreateFilledMask(playback.Request.Width, playback.Request.Height, terminalFill),
playback.Request);
playback.Gfx.CompleteMovieMaskTransition(playback.Request.SurfaceSlot);
}
private MovieMaskPlayback? RemoveMovieMaskPlayback(long playbackId)
{
lock (_movieMaskLock)
{
if (!_movieMasksByPlayback.Remove(playbackId, out var playback)) return null;
return playback;
}
}
private void AbandonMovieMaskPlayback(long playbackId)
{
MovieMaskPlayback? playback = RemoveMovieMaskPlayback(playbackId);
if (playback != null)
{
ForgetMovieMaskSurface(playback.Request.SurfaceSlot, playbackId);
playback.Gfx.CompleteMovieMaskTransition(playback.Request.SurfaceSlot);
}
}
private void ForgetMovieMaskSurface(int surfaceSlot, long playbackId)
{
lock (_movieMaskLock)
if (_movieMaskPlaybackBySurface.GetValueOrDefault(surfaceSlot) == playbackId)
_movieMaskPlaybackBySurface.Remove(surfaceSlot);
}
private RgbaImage? Decode(AssetEntry asset)
{
lock (_imageLock)

View File

@@ -29,5 +29,6 @@ internal interface IMovieDecoder : IDisposable
internal interface IMovieDecoderFactory
{
IMovieDecoder Open(MoviePayload movie, long initialPositionMs = 0);
IMovieDecoder Open(
MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null);
}

View File

@@ -2129,7 +2129,8 @@ public partial class Main : Godot.Control
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId, long movieFlags,
long initialPositionMs,
long initialPositionMs, long startDelayMs,
long? presentationDurationMs,
out long? stopTimeMs)
{
stopTimeMs = null;
@@ -2138,7 +2139,7 @@ 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,
initialPositionMs);
initialPositionMs, startDelayMs, presentationDurationMs);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
@@ -2180,6 +2181,9 @@ public partial class Main : Godot.Control
if (_host == null) return;
foreach (var (playbackId, movie) in _movies)
{
long elapsedMs = (long)Stopwatch.GetElapsedTime(
movie.StartedAtTimestamp).TotalMilliseconds;
if (elapsedMs < movie.StartDelayMs) continue;
bool frameWasAlreadySeen = _movieFrameSeen.Contains(playbackId);
_movieAudio.TryGetValue(playbackId, out var audio);
if (frameWasAlreadySeen) audio?.Update();
@@ -2196,8 +2200,7 @@ public partial class Main : Godot.Control
audio?.Update();
}
}
bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds
>= movie.WatchdogMs;
bool watchdogExpired = elapsedMs >= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
{
if (movie.Decoder.Failure is { } failure)

View File

@@ -5,21 +5,31 @@ 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 InitialPositionMs,
long StartedAtTimestamp, long WatchdogMs)
long StartedAtTimestamp, long StartDelayMs,
long? PresentationDurationMs, long WatchdogMs)
{
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
IMovieDecoderFactory factory, long movieFlags = 0,
long initialPositionMs = 0)
long initialPositionMs = 0, long startDelayMs = 0,
long? presentationDurationMs = null)
{
ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload, Math.Max(0, initialPositionMs));
long safeDelayMs = Math.Max(0, startDelayMs);
long? safeDurationMs = presentationDurationMs is >= 0
? Math.Max(0, presentationDurationMs.Value)
: null;
IMovieDecoder decoder = factory.Open(
payload, Math.Max(0, initialPositionMs), safeDurationMs);
long remainingMs = decoder.StopTimeMs is >= 0 and var stopTime
? Math.Max(0, stopTime - decoder.InitialPositionMs)
: -1;
long watchdogBasis = safeDurationMs ?? remainingMs;
if (watchdogBasis >= 0) watchdogBasis = checked(watchdogBasis + safeDelayMs);
return new MovieRuntime(name, assetId, resourceId, decoder, movieFlags,
decoder.InitialPositionMs, Stopwatch.GetTimestamp(),
remainingMs >= 0
? Math.Clamp(remainingMs + 2000, 5000, 300000)
safeDelayMs, safeDurationMs,
watchdogBasis >= 0
? Math.Clamp(watchdogBasis + 2000, 5000, 300000)
: 30000);
}
}