Harden movie playback lifecycle and diagnostics

This commit is contained in:
gamer147
2026-07-22 09:44:57 -04:00
parent 816673cf6a
commit 4f5137a98f
25 changed files with 1145 additions and 147 deletions

View File

@@ -4,12 +4,6 @@ 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);

View File

@@ -14,6 +14,12 @@ internal readonly record struct FfmpegMovieInfo(
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
internal interface IFfmpegFrameSource : IDisposable
{
FfmpegMovieInfo Info { get; }
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
}
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
{

View File

@@ -22,9 +22,7 @@ public sealed class GodotAdvHost : IHost
private readonly Dictionary<int, RgbaImage> _surfaceImages = new();
private readonly Dictionary<int, long> _surfaceColorKeys = new();
private readonly Dictionary<int, long> _surfaceResources = new(); // surface slot -> packed catalog id
private readonly Dictionary<long, (RgbaImage Image, string Name, int AssetId)> _movieFrames = new();
private readonly Dictionary<int, long> _movieBySurface = new();
private readonly HashSet<long> _completedMovies = new();
private readonly MovieSurfaceRegistry _movieSurfaces = new();
private readonly string?[] _sfxNames = new string?[10]; // SC0000 native channel subset
// slot -> dimensions of the currently allocated surface. Slot 0 begins as the engine's 800x600
// primary surface, but op 0x1fa releases it like any other slot; subsequent size queries must return 0x0.
@@ -869,15 +867,13 @@ public sealed class GodotAdvHost : IHost
/// loose-first asset store.</summary>
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveResIdTexture(long resId)
{
lock (_imageLock)
{
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 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;
}
if (_movieSurfaces.TryResolveResource(resId, out var movie) && movie != null)
return (movie.Image, movie.Name, movie.AssetId, true);
// Movie payloads use the same .AGF extension as still images. Do not misclassify the MPEG program
// stream before its first frame or during the cleanup frame after its surface binding is detached.
// Packed catalog identity is immutable, so a resource which entered the typed movie path remains
// a movie even when it has no live playback.
if (_movieSurfaces.IsKnownMovieResource(resId)) return null;
var asset = _res.ResolveTexture(resId);
var image = asset != null ? Decode(asset) : null;
return asset != null && image != null ? (image, asset.Name, asset.PackedId, false) : null;
@@ -886,27 +882,49 @@ public sealed class GodotAdvHost : IHost
public (RgbaImage Image, string Name, int AssetId, bool IsDynamic)? ResolveSurfaceTexture(
int surfaceSlot, long fallbackResourceId)
{
if (_movieSurfaces.TryResolveSurface(surfaceSlot, out var movie) && movie != null)
return (movie.Image, movie.Name, movie.AssetId, true);
if (_movieSurfaces.IsBound(surfaceSlot)) return null;
lock (_imageLock)
if (_surfaceImages.TryGetValue(surfaceSlot, out var surface))
return (surface, $"<surface:{surfaceSlot}>", int.MinValue + surfaceSlot, true);
return fallbackResourceId != 0 ? ResolveResIdTexture(fallbackResourceId) : null;
}
public bool IsMovieSurfaceBound(int surfaceSlot) => _movieSurfaces.IsBound(surfaceSlot);
public long? PlayMovieToSurface(long resourceId, int surfaceSlot, long movieFlags, long syncMask)
{
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,
out long? stopTimeMs);
out long? stopTimeMs, out _);
return stopTimeMs ?? 0;
}
public bool IsMovieSurfaceActive(int surfaceSlot)
=> _movieSurfaces.IsActive(surfaceSlot);
public GodotHostDiagnosticSnapshot CaptureDiagnosticSnapshot()
{
lock (_imageLock)
return _movieBySurface.TryGetValue(surfaceSlot, out long resourceId)
&& !_completedMovies.Contains(resourceId);
IReadOnlyList<MovieSurfaceDiagnostic> movies = _movieSurfaces.Snapshot();
IReadOnlyList<long> completed = _movieSurfaces.CompletedPlaybackIds();
bool screenTransitionActive;
lock (_screenTransitionLock) screenTransitionActive = _screenTransition != null;
return new GodotHostDiagnosticSnapshot(
CurrentScene,
IsWaiting,
IsTransitionWaiting,
IsSleeping,
IsTextRevealing,
_modalMovieWaiting,
_advPagePresentationSuspended,
_messageSkipActive,
screenTransitionActive,
TransitionStartedAtMs,
movies,
completed);
}
public void PlayModalMovieToSurface(long resourceId, int surfaceSlot, long movieFlags)
@@ -923,22 +941,22 @@ public sealed class GodotAdvHost : IHost
try
{
if (!StartMovie(asset, resourceId, surfaceSlot, movieFlags, 0, modal: true,
out _)) return;
out _, out long playbackId)) return;
_timeline?.State("modal-movie-wait", new()
{
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = asset.Name,
["resource"] = resourceId, ["playback"] = playbackId,
["surface"] = surfaceSlot, ["file"] = asset.Name,
});
while (!_stopping && !_modalMovieCancelled)
{
lock (_imageLock)
if (_completedMovies.Contains(resourceId)) break;
if (!_movieSurfaces.IsActive(surfaceSlot)) break;
_frameSignal.WaitOne(50);
}
// Cancellation is a completed modal presentation from the script's perspective. The
// wrapper's following surface-release opcode performs the ordinary decoder teardown.
if (_modalMovieCancelled)
lock (_imageLock) _completedMovies.Add(resourceId);
_movieSurfaces.Complete(playbackId);
_timeline?.State("running", new()
{
["modal_movie_complete"] = !_modalMovieCancelled,
@@ -953,16 +971,18 @@ public sealed class GodotAdvHost : IHost
}
private bool StartMovie(AssetEntry asset, long resourceId, int surfaceSlot, long movieFlags,
long syncMask, bool modal, out long? stopTimeMs)
long syncMask, bool modal, out long? stopTimeMs, out long playbackId)
{
stopTimeMs = null;
// 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);
// A playback is a surface-owned instance, not the shared resource id. BTL can schedule the same
// 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);
lock (_imageLock)
{
_movieBySurface[surfaceSlot] = resourceId;
_completedMovies.Remove(resourceId);
_surfaceImages.Remove(surfaceSlot);
_surfaceColorKeys.Remove(surfaceSlot);
}
_slotDims[surfaceSlot] = (800, 600); // SC0000 creates this native-sized surface immediately beforehand.
try
@@ -970,30 +990,24 @@ public sealed class GodotAdvHost : IHost
var movie = _res.ReadMovie(asset);
_timeline?.Event("movie-start", new()
{
["resource"] = resourceId, ["surface"] = surfaceSlot, ["file"] = movie.Name,
["resource"] = resourceId, ["playback"] = playbackId,
["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
});
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, resourceId, asset.PackedId,
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId,
out stopTimeMs);
if (!started)
{
stopTimeMs = 0;
NotifyMovieCompleted(resourceId);
NotifyMovieCompleted(playbackId);
}
return started;
}
catch (System.Exception e)
{
lock (_imageLock)
{
if (_movieBySurface.TryGetValue(surfaceSlot, out long registered) && registered == resourceId)
_movieBySurface.Remove(surfaceSlot);
_movieFrames.Remove(resourceId);
_completedMovies.Remove(resourceId);
}
_movieSurfaces.Abandon(playbackId, out _);
_slotDims.Remove(surfaceSlot);
stopTimeMs = 0;
NotifyMovieCompleted(resourceId);
Godot.GD.Print($"movie read failed {asset.Name}: {e.Message}");
return false;
}
@@ -1002,28 +1016,11 @@ public sealed class GodotAdvHost : IHost
public void ReleaseSurface(int slot)
{
lock (_screenTransitionLock) _renderTargetSnapshots.Remove(slot);
long resourceId;
MovieSurfaceRelease movieRelease = _movieSurfaces.ReleaseIfCompleted(slot);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Active)
return; // Static surface setup before 0x21c must not evict an active movie playback.
lock (_imageLock)
{
if (!_movieBySurface.Remove(slot, out resourceId))
{
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
lock (_textLock)
{
_surfaceText.Remove(slot);
_surfaceResources.Remove(slot);
}
_slotDims.Remove(slot);
return;
}
if (!_completedMovies.Contains(resourceId))
{
_movieBySurface[slot] = resourceId;
return; // SC0000 prepares following static surfaces before 0x21c; the movie remains retained.
}
_movieFrames.Remove(resourceId);
_completedMovies.Remove(resourceId);
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
}
@@ -1033,8 +1030,17 @@ public sealed class GodotAdvHost : IHost
_surfaceResources.Remove(slot);
}
_slotDims.Remove(slot);
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["surface"] = slot });
_main.CallDeferred("StopMovie", resourceId);
if (movieRelease.Kind == MovieSurfaceReleaseKind.Released)
{
var binding = movieRelease.Binding;
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
["playback"] = binding.PlaybackId,
["surface"] = slot,
});
_main.CallDeferred("StopMovie", binding.PlaybackId);
}
}
public void ClearRenderTarget(int surfaceSlot)
@@ -1049,7 +1055,7 @@ public sealed class GodotAdvHost : IHost
public void ReleaseSurfaceRange(int firstSlot, int count)
{
var stoppedMovies = new System.Collections.Generic.HashSet<long>();
IReadOnlyList<MovieSurfaceBinding> stoppedMovies = _movieSurfaces.ReleaseRange(firstSlot, count);
int end = checked(firstSlot + count);
lock (_screenTransitionLock)
for (int slot = firstSlot; slot < end; slot++) _renderTargetSnapshots.Remove(slot);
@@ -1057,12 +1063,6 @@ public sealed class GodotAdvHost : IHost
{
for (int slot = firstSlot; slot < end; slot++)
{
if (_movieBySurface.Remove(slot, out long resourceId))
{
stoppedMovies.Add(resourceId);
_movieFrames.Remove(resourceId);
_completedMovies.Remove(resourceId);
}
_surfaceImages.Remove(slot);
_surfaceColorKeys.Remove(slot);
_slotDims.Remove(slot);
@@ -1076,37 +1076,36 @@ public sealed class GodotAdvHost : IHost
_surfaceResources.Remove(slot);
}
}
foreach (long resourceId in stoppedMovies)
foreach (MovieSurfaceBinding binding in stoppedMovies)
{
_timeline?.Event("movie-stop", new() { ["resource"] = resourceId, ["range_release"] = true });
_main.CallDeferred("StopMovie", resourceId);
_timeline?.Event("movie-stop", new()
{
["resource"] = binding.ResourceId,
["playback"] = binding.PlaybackId,
["range_release"] = true,
});
_main.CallDeferred("StopMovie", binding.PlaybackId);
}
_timeline?.Event("surface-range-release", new() { ["first"] = firstSlot, ["count"] = count });
}
// Main-thread decoder handoff. Replacing the newest frame mirrors the native texture renderer's
// sample callback: the retained object keeps its surface binding while only the surface pixels change.
public void PublishMovieFrame(long resourceId, string name, int assetId, RgbaImage frame)
public void PublishMovieFrame(long playbackId, string name, int assetId, RgbaImage frame)
{
lock (_imageLock) _movieFrames[resourceId] = (frame, name, assetId);
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
if (_movieSurfaces.PublishFrame(playbackId, frame, name, assetId))
System.Threading.Interlocked.Exchange(ref _presentRequested, 1);
}
public void NotifyMovieCompleted(long resourceId)
public void NotifyMovieCompleted(long playbackId)
{
lock (_imageLock)
if (!_completedMovies.Add(resourceId)) return;
_timeline?.Event("movie-complete", new() { ["resource"] = resourceId });
if (!_movieSurfaces.Complete(playbackId)) return;
_timeline?.Event("movie-complete", new() { ["playback"] = playbackId });
_frameSignal.Set();
}
private bool HasActiveMoviePresentation()
{
lock (_imageLock)
foreach (long resourceId in _movieBySurface.Values)
if (!_completedMovies.Contains(resourceId)) return true;
return false;
}
=> _movieSurfaces.HasActivePlayback;
private RgbaImage? Decode(AssetEntry asset)
{
@@ -1254,6 +1253,11 @@ public sealed class GodotAdvHost : IHost
}
public readonly record struct SurfaceTextDraw(int X, int Y, string Text, AdvTextStyle Style);
public sealed record GodotHostDiagnosticSnapshot(
string CurrentScene, bool IsInputWaiting, bool IsTransitionWaiting, bool IsSleeping,
bool IsTextRevealing, bool IsModalMovieWaiting, bool IsAdvPagePresentationSuspended,
bool IsMessageSkipActive, bool IsScreenTransitionActive, long TransitionStartedAtMs,
IReadOnlyList<MovieSurfaceDiagnostic> MovieSurfaces, IReadOnlyList<long> CompletedMoviePlaybackIds);
public readonly record struct LegacyScreenTransitionSnapshot(
IReadOnlyList<RenderObject> Source, IReadOnlyList<RenderObject> Target, double Progress);

View File

@@ -8,9 +8,13 @@ using Age.Engine.Diagnostics;
// engine fact delivered over the trace seam.
public sealed class GodotTraceSink : ITraceSink
{
private const int RecentStepCapacity = 128;
private readonly GodotTimelineLog? _timeline;
private readonly PageLocatorState _locator;
private readonly object _snapshotLock = new();
private readonly Stack<string> _scripts = new();
private readonly Queue<GodotTraceStepSnapshot> _recentSteps = new();
private GodotTraceStepSnapshot? _latestStep;
public GodotTraceSink(PageLocatorState locator, GodotTimelineLog? timeline = null)
{ _locator = locator; _timeline = timeline; }
// The page locator needs the exact script/offset even when the heavier timeline log is disabled.
@@ -28,8 +32,13 @@ public sealed class GodotTraceSink : ITraceSink
}
if (e.Kind == TraceEventKind.FrameEnter && e.Name != null)
{
_scripts.Push(e.Name);
PublishCallStack();
string[] callStack;
lock (_snapshotLock)
{
_scripts.Push(e.Name);
callStack = CurrentCallStackLocked();
}
_locator.CallStack(callStack);
_timeline?.Event("frame-enter", new()
{
["name"] = e.Name, ["depth"] = e.Depth,
@@ -42,12 +51,25 @@ public sealed class GodotTraceSink : ITraceSink
{
["name"] = e.Name, ["depth"] = e.Depth, ["outcome"] = e.Text,
});
_scripts.Pop();
PublishCallStack();
string[] callStack;
lock (_snapshotLock)
{
if (_scripts.Count > 0) _scripts.Pop();
callStack = CurrentCallStackLocked();
}
_locator.CallStack(callStack);
}
else if (e.Kind == TraceEventKind.Step && e.Ins != null)
{
string script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
string script;
lock (_snapshotLock)
{
script = _scripts.Count > 0 ? _scripts.Peek() : "<unknown>";
if (_recentSteps.Count == RecentStepCapacity) _recentSteps.Dequeue();
var step = new GodotTraceStepSnapshot(script, e.Ins.Offset, e.Opcode, e.Depth);
_latestStep = step;
_recentSteps.Enqueue(step);
}
_locator.Step(script, e.Ins.Offset);
_timeline?.Step(script, e.Ins.Offset, e.Opcode, e.Depth);
}
@@ -60,10 +82,31 @@ public sealed class GodotTraceSink : ITraceSink
_timeline?.State("halted", new() { ["reason"] = e.Text, ["steps"] = e.Steps });
}
private void PublishCallStack()
public GodotTraceSnapshot Snapshot()
{
lock (_snapshotLock)
{
GodotTraceStepSnapshot? current = _latestStep;
return new GodotTraceSnapshot(
current?.Script ?? (_scripts.Count > 0 ? _scripts.Peek() : "<unknown>"),
current?.Offset ?? -1,
current?.Opcode ?? -1,
current?.Depth ?? System.Math.Max(0, _scripts.Count - 1),
CurrentCallStackLocked(),
_recentSteps.ToArray());
}
}
private string[] CurrentCallStackLocked()
{
var stack = _scripts.ToArray();
System.Array.Reverse(stack);
_locator.CallStack(stack);
return stack;
}
}
public sealed record GodotTraceStepSnapshot(string Script, int Offset, int Opcode, int Depth);
public sealed record GodotTraceSnapshot(string CurrentScript, int CurrentOffset, int CurrentOpcode,
int CurrentDepth, IReadOnlyList<string> CallStack,
IReadOnlyList<GodotTraceStepSnapshot> RecentSteps);

View File

@@ -393,6 +393,12 @@ public partial class Main : Godot.Control
GetViewport().SetInputAsHandled();
return;
}
if (e is InputEventKey diagnosticKey && diagnosticKey.Keycode == Key.F6)
{
if (diagnosticKey.Pressed && !diagnosticKey.Echo) CaptureStallDiagnostic();
GetViewport().SetInputAsHandled();
return;
}
if (_debugSceneLauncher?.Visible == true)
{
if (e is InputEventKey escape && escape.Pressed && !escape.Echo && escape.Keycode == Key.Escape)
@@ -501,6 +507,86 @@ public partial class Main : Godot.Control
private static bool IsAdvanceAction(int action) => action is 4 or 5;
private static bool HasAdvanceAction(int mask) => (mask & ((1 << 4) | (1 << 5))) != 0;
private void CaptureStallDiagnostic()
{
try
{
long nowMs = _clock.NowMs;
GodotTraceSnapshot trace = _trace.Snapshot();
var activeMovies = _movies
.OrderBy(pair => pair.Key)
.Select(pair => new
{
playback_id = pair.Key,
resource_id = pair.Value.ResourceId,
name = pair.Value.Name,
asset_id = pair.Value.AssetId,
stop_time_ms = pair.Value.Decoder.StopTimeMs,
decoder_completed = pair.Value.Decoder.IsCompleted,
decoder_failure = pair.Value.Decoder.Failure,
frame_seen = _movieFrameSeen.Contains(pair.Key),
completion_notified = _movieCompletionNotified.Contains(pair.Key),
watchdog_ms = pair.Value.WatchdogMs,
elapsed_ms = (long)Stopwatch.GetElapsedTime(pair.Value.StartedAtTimestamp).TotalMilliseconds,
})
.ToArray();
var pendingMovies = _pendingMovies
.OrderBy(pair => pair.Key)
.Select(pair => new
{
playback_id = pair.Key,
resource_id = pair.Value.ResourceId,
name = pair.Value.Name,
asset_id = pair.Value.AssetId,
stop_time_ms = pair.Value.Decoder.StopTimeMs,
decoder_completed = pair.Value.Decoder.IsCompleted,
decoder_failure = pair.Value.Decoder.Failure,
})
.ToArray();
var snapshot = new
{
format_version = 1,
captured_utc = System.DateTimeOffset.UtcNow.ToString("O"),
render_frame = _timelineFrame,
clock_ms = nowMs,
vm = new
{
steps = _vm.Steps,
halt_reason = _vm.HaltReason,
done = _done,
trace,
},
host = _host.CaptureDiagnosticSnapshot(),
gfx = _vm.Gfx.CaptureDiagnosticSnapshot(nowMs),
active_movies = activeMovies,
pending_movies = pendingMovies,
};
string directory = ProjectSettings.GlobalizePath("user://diagnostics");
System.IO.Directory.CreateDirectory(directory);
string path = System.IO.Path.Combine(directory,
$"stall-{System.DateTimeOffset.Now:yyyyMMdd-HHmmss-fff}.json");
var jsonOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
};
System.IO.File.WriteAllText(path, JsonSerializer.Serialize(snapshot, jsonOptions));
string coordinate = trace.CurrentOffset >= 0
? $"{System.IO.Path.GetFileNameWithoutExtension(trace.CurrentScript).ToUpperInvariant()}@0x{trace.CurrentOffset:x}"
: trace.CurrentScript;
string clipboard = $"{coordinate} · stall snapshot {path}";
DisplayServer.ClipboardSet(clipboard);
_status.Text = $"Diagnostic saved: {coordinate} (path copied)";
GD.Print($"[diagnostic] stall snapshot {coordinate} -> {path}");
}
catch (System.Exception exception)
{
_status.Text = "Diagnostic capture failed; see Godot log.";
GD.Print($"[diagnostic] stall snapshot failed: {exception}");
}
}
private void ToggleDebugSceneLauncher()
{
if (_debugSceneLauncher == null) return;
@@ -680,6 +766,7 @@ public partial class Main : Godot.Control
var surfaceTexture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, v.SurfaceResId)
: null;
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
string outcome;
if (v.SurfaceTransition is { } transition)
{
@@ -709,7 +796,8 @@ public partial class Main : Godot.Control
}
else
{
var texture = surfaceTexture ?? _host.ResolveResIdTexture(v.SurfaceResId);
var texture = surfaceTexture
?? (movieSurfaceBound ? null : _host.ResolveResIdTexture(v.SurfaceResId));
if (texture == null) outcome = $"SKIP(resId=0x{v.SurfaceResId:x} UNRESOLVED)";
else
{
@@ -889,6 +977,7 @@ public partial class Main : Godot.Control
var texture = rawObject != null
? _host.ResolveSurfaceTexture(rawObject.SourceSlot, source.SurfaceResId)
: null;
bool movieSurfaceBound = rawObject != null && _host.IsMovieSurfaceBound(rawObject.SourceSlot);
if (source.SurfaceResId == 0 && texture == null)
{
if (source.Blend == BlendKind.Opaque) continue;
@@ -897,7 +986,7 @@ public partial class Main : Godot.Control
}
else
{
texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
if (!movieSurfaceBound) texture ??= _host.ResolveResIdTexture(source.SurfaceResId);
if (texture == null) continue;
BlitLayer(texture.Value.Image, texture.Value.AssetId, source.ColorKey, source.Tint, source.TintStrength / 255f,
source.SrcX, source.SrcY, source.W, source.H, affine, opacity, source.MultiplyTint,
@@ -1177,17 +1266,18 @@ public partial class Main : Godot.Control
CreateTween().TweenProperty(_bgm, "volume_db", targetDb, realDurationSeconds);
}
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long resourceId, int assetId,
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId,
out long? stopTimeMs)
{
stopTimeMs = null;
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open(assetName, assetId, payload, _movieDecoderFactory);
var runtime = MovieRuntime.Open(assetName, assetId, resourceId, payload, _movieDecoderFactory);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(resourceId, runtime))
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
return true;
}
catch (System.Exception e)
@@ -1199,50 +1289,52 @@ public partial class Main : Godot.Control
private void AdoptPendingMovies()
{
foreach (var (resourceId, _) in _pendingMovies)
foreach (var (playbackId, _) in _pendingMovies)
{
if (!_pendingMovies.TryRemove(resourceId, out var movie)) continue;
if (_movies.Remove(resourceId, out var prior)) prior.Decoder.Dispose();
_movies[resourceId] = movie;
_movieCompletionNotified.Remove(resourceId);
GD.Print($"movie started {movie.Name} ({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
_movies[playbackId] = movie;
_movieCompletionNotified.Remove(playbackId);
GD.Print($"movie started {movie.Name} playback={playbackId} " +
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
}
}
private void UpdateMovieFrames()
{
if (_host == null) return;
foreach (var (resourceId, movie) in _movies)
foreach (var (playbackId, movie) in _movies)
{
if (movie.Decoder.TryTakeFrame(out var frame))
{
_host.PublishMovieFrame(resourceId, movie.Name, movie.AssetId, frame);
if (_movieFrameSeen.Add(resourceId))
GD.Print($"movie first frame {movie.Name}: {frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
if (_movieFrameSeen.Add(playbackId))
GD.Print($"movie first frame {movie.Name} playback={playbackId}: " +
$"{frame.Width}x{frame.Height} RGBA8 at render frame {_timelineFrame}");
}
bool watchdogExpired = Stopwatch.GetElapsedTime(movie.StartedAtTimestamp).TotalMilliseconds
>= movie.WatchdogMs;
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId))
if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(playbackId))
{
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);
_host.NotifyMovieCompleted(playbackId);
}
}
}
public void StopMovie(long resourceId)
public void StopMovie(long playbackId)
{
if (_pendingMovies.TryRemove(resourceId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(resourceId, out var movie))
if (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(playbackId, out var movie))
{
movie.Decoder.Dispose();
GD.Print($"movie stopped {movie.Name} at render frame {_timelineFrame}");
GD.Print($"movie stopped {movie.Name} playback={playbackId} at render frame {_timelineFrame}");
}
_movieFrameSeen.Remove(resourceId);
_movieCompletionNotified.Remove(resourceId);
_movieFrameSeen.Remove(playbackId);
_movieCompletionNotified.Remove(playbackId);
}
public void AppendLine(string text) => _text.Text += text + "\n";

View File

@@ -3,15 +3,15 @@ using System.Diagnostics;
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, IMovieDecoder Decoder,
internal sealed record MovieRuntime(string Name, int AssetId, long ResourceId, IMovieDecoder Decoder,
long StartedAtTimestamp, long WatchdogMs)
{
public static MovieRuntime Open(string name, int assetId, MoviePayload payload,
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
IMovieDecoderFactory factory)
{
ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload);
return new MovieRuntime(name, assetId, decoder, Stopwatch.GetTimestamp(),
return new MovieRuntime(name, assetId, resourceId, decoder, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? Math.Clamp(stopTime + 2000, 5000, 300000)
: 30000);

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Sys4;
internal readonly record struct MovieSurfaceBinding(long PlaybackId, long ResourceId, int SurfaceSlot);
internal sealed record MovieSurfaceFrame(RgbaImage Image, string Name, int AssetId);
public sealed record MovieSurfaceDiagnostic(int SurfaceSlot, long PlaybackId, long ResourceId,
bool Completed, bool HasFrame, string? Name);
internal enum MovieSurfaceReleaseKind { NotBound, Active, Released }
internal readonly record struct MovieSurfaceRelease(MovieSurfaceReleaseKind Kind,
MovieSurfaceBinding Binding);
/// <summary>Instance-keyed movie/surface ownership. A resource may be played more than once concurrently;
/// completion and frames therefore belong to a playback, never to the shared asset id.</summary>
internal sealed class MovieSurfaceRegistry
{
private readonly object _lock = new();
private readonly Dictionary<int, MovieSurfaceBinding> _bySurface = new();
private readonly Dictionary<long, MovieSurfaceBinding> _byPlayback = new();
private readonly Dictionary<long, MovieSurfaceFrame> _frames = new();
private readonly HashSet<long> _completed = new();
private readonly HashSet<long> _knownMovieResources = new();
private long _nextPlaybackId;
public MovieSurfaceBinding Begin(int surfaceSlot, long resourceId,
out MovieSurfaceBinding? replaced)
{
lock (_lock)
{
replaced = _bySurface.TryGetValue(surfaceSlot, out var prior) ? prior : null;
if (replaced.HasValue) RemoveLocked(prior);
var binding = new MovieSurfaceBinding(++_nextPlaybackId, resourceId, surfaceSlot);
_knownMovieResources.Add(resourceId);
_bySurface[surfaceSlot] = binding;
_byPlayback[binding.PlaybackId] = binding;
return binding;
}
}
public bool PublishFrame(long playbackId, RgbaImage image, string name, int assetId)
{
lock (_lock)
{
if (!_byPlayback.ContainsKey(playbackId)) return false;
_frames[playbackId] = new MovieSurfaceFrame(image, name, assetId);
return true;
}
}
public bool Complete(long playbackId)
{
lock (_lock)
return _byPlayback.ContainsKey(playbackId) && _completed.Add(playbackId);
}
public bool IsActive(int surfaceSlot)
{
lock (_lock)
return _bySurface.TryGetValue(surfaceSlot, out var binding)
&& !_completed.Contains(binding.PlaybackId);
}
public bool IsBound(int surfaceSlot)
{
lock (_lock) return _bySurface.ContainsKey(surfaceSlot);
}
public bool HasActivePlayback
{
get
{
lock (_lock)
return _byPlayback.Keys.Any(playbackId => !_completed.Contains(playbackId));
}
}
public bool TryResolveSurface(int surfaceSlot, out MovieSurfaceFrame? frame)
{
lock (_lock)
{
if (_bySurface.TryGetValue(surfaceSlot, out var binding)
&& _frames.TryGetValue(binding.PlaybackId, out var found))
{
frame = found;
return true;
}
frame = null;
return false;
}
}
public bool TryResolveResource(long resourceId, out MovieSurfaceFrame? frame)
{
lock (_lock)
{
foreach (var binding in _byPlayback.Values
.Where(binding => binding.ResourceId == resourceId)
.OrderByDescending(binding => binding.PlaybackId))
{
if (!_frames.TryGetValue(binding.PlaybackId, out var found)) continue;
frame = found;
return true;
}
frame = null;
return false;
}
}
/// <summary>Catalog ids are immutable within a mounted resource set. Remembering that an id entered
/// the typed movie path prevents a cleanup-frame render snapshot from treating its .AGF-named MPEG
/// payload as a still image after the last live surface binding has been detached.</summary>
public bool IsKnownMovieResource(long resourceId)
{
lock (_lock) return _knownMovieResources.Contains(resourceId);
}
public MovieSurfaceRelease ReleaseIfCompleted(int surfaceSlot)
{
lock (_lock)
{
if (!_bySurface.TryGetValue(surfaceSlot, out var binding))
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.NotBound, default);
if (!_completed.Contains(binding.PlaybackId))
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.Active, binding);
RemoveLocked(binding);
return new MovieSurfaceRelease(MovieSurfaceReleaseKind.Released, binding);
}
}
public bool Abandon(long playbackId, out MovieSurfaceBinding binding)
{
lock (_lock)
{
if (!_byPlayback.TryGetValue(playbackId, out binding)) return false;
RemoveLocked(binding);
return true;
}
}
public IReadOnlyList<MovieSurfaceBinding> ReleaseRange(int firstSlot, int count)
{
int end = checked(firstSlot + count);
lock (_lock)
{
var released = _bySurface.Values
.Where(binding => binding.SurfaceSlot >= firstSlot && binding.SurfaceSlot < end)
.OrderBy(binding => binding.SurfaceSlot)
.ToArray();
foreach (var binding in released) RemoveLocked(binding);
return released;
}
}
public IReadOnlyList<MovieSurfaceDiagnostic> Snapshot()
{
lock (_lock)
return _bySurface.Values
.OrderBy(binding => binding.SurfaceSlot)
.Select(binding => new MovieSurfaceDiagnostic(
binding.SurfaceSlot,
binding.PlaybackId,
binding.ResourceId,
_completed.Contains(binding.PlaybackId),
_frames.ContainsKey(binding.PlaybackId),
_frames.TryGetValue(binding.PlaybackId, out var frame) ? frame.Name : null))
.ToArray();
}
public IReadOnlyList<long> CompletedPlaybackIds()
{
lock (_lock) return _completed.OrderBy(id => id).ToArray();
}
private void RemoveLocked(MovieSurfaceBinding binding)
{
_bySurface.Remove(binding.SurfaceSlot);
_byPlayback.Remove(binding.PlaybackId);
_frames.Remove(binding.PlaybackId);
_completed.Remove(binding.PlaybackId);
}
}