Move movie runtime support to frontend

This commit is contained in:
gamer147
2026-08-03 11:29:47 -04:00
parent 511badae6d
commit c5d32b600f
9 changed files with 22 additions and 14 deletions

View File

@@ -1,35 +0,0 @@
using System;
using Age.Engine.Model;
using Age.Engine.Sys4;
internal readonly record struct MovieAudioInfo(int SampleRate, int Channels);
internal sealed record MovieAudioChunk(float[] InterleavedStereo, int FrameCount, long PresentationTimeMs);
/// <summary>
/// Platform-neutral movie playback boundary. Construction is synchronous so metadata needed by the VM is
/// available before op 0x236 returns; frame delivery and completion remain asynchronous.
/// </summary>
internal interface IMovieDecoder : IDisposable
{
long? StopTimeMs { get; }
long InitialPositionMs => 0;
bool IsCompleted { get; }
string? Failure { get; }
long? FirstFramePresentationTimeMs => null;
bool TryTakeFrame(out RgbaImage frame);
MovieAudioInfo? AudioInfo => null;
bool AudioDecodingCompleted => true;
bool TryTakeAudioChunk(out MovieAudioChunk chunk)
{
chunk = default!;
return false;
}
void AdvancePlaybackClock(long elapsedMilliseconds) { }
void MarkAudioSubmitted() { }
}
internal interface IMovieDecoderFactory
{
IMovieDecoder Open(
MoviePayload movie, long initialPositionMs = 0, long? presentationDurationMs = null);
}

View File

@@ -1,50 +0,0 @@
using System;
internal readonly record struct MovieAudioAdjustment(long GapFrames, int SkipFrames);
/// <summary>
/// Converts coarse MPEG timestamps into PCM alignment decisions without turning sub-millisecond
/// timestamp quantization into an audible splice at every decoded block boundary.
/// </summary>
internal static class MovieAudioTimeline
{
private const int TimestampJitterToleranceMilliseconds = 2;
public static long PresentationFrame(long presentationTimeMs, int sampleRate)
{
if (presentationTimeMs < 0)
throw new ArgumentOutOfRangeException(nameof(presentationTimeMs));
if (sampleRate <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleRate));
return checked((long)Math.Round(
presentationTimeMs * (double)sampleRate / 1000.0,
MidpointRounding.AwayFromZero));
}
public static MovieAudioAdjustment Align(
long submittedFrames,
long targetFrame,
int chunkFrames,
int sampleRate,
bool timelineAnchored)
{
if (submittedFrames < 0)
throw new ArgumentOutOfRangeException(nameof(submittedFrames));
if (targetFrame < 0)
throw new ArgumentOutOfRangeException(nameof(targetFrame));
if (chunkFrames <= 0)
throw new ArgumentOutOfRangeException(nameof(chunkFrames));
if (sampleRate <= 0)
throw new ArgumentOutOfRangeException(nameof(sampleRate));
long delta = targetFrame - submittedFrames;
long tolerance = timelineAnchored
? Math.Max(1L, (sampleRate * (long)TimestampJitterToleranceMilliseconds + 999L) / 1000L)
: 0L;
if (delta > tolerance)
return new MovieAudioAdjustment(delta, 0);
if (delta < -tolerance)
return new MovieAudioAdjustment(0, checked((int)Math.Min(chunkFrames, -delta)));
return default;
}
}

View File

@@ -1,35 +0,0 @@
using System;
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, long ResourceId, IMovieDecoder Decoder,
long MovieFlags, long InitialPositionMs,
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 startDelayMs = 0,
long? presentationDurationMs = null)
{
ArgumentNullException.ThrowIfNull(factory);
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(),
safeDelayMs, safeDurationMs,
watchdogBasis >= 0
? Math.Clamp(watchdogBasis + 2000, 5000, 300000)
: 30000);
}
}

View File

@@ -1,184 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Age.Engine.Model;
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)
{
long newestPlaybackId = long.MinValue;
MovieSurfaceFrame? newestFrame = null;
foreach (var binding in _byPlayback.Values)
{
if (binding.ResourceId != resourceId || binding.PlaybackId <= newestPlaybackId ||
!_frames.TryGetValue(binding.PlaybackId, out var found))
continue;
newestPlaybackId = binding.PlaybackId;
newestFrame = found;
}
frame = newestFrame;
return newestFrame != null;
}
}
/// <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);
}
}

View File

@@ -1,72 +0,0 @@
using System;
using System.Buffers.Binary;
/// <summary>Godot-specific WAV input adapter. Native AGE decodes only the first declared RIFF/WAVE
/// extent, while Godot walks the entire supplied buffer. AGE's Japanese assets also commonly store
/// CP932 strings in INFO metadata that Godot assumes is UTF-8. Prepare a transient decoder copy that
/// follows AGE's first-RIFF boundary and removes only INFO metadata within it.</summary>
internal static class RiffWaveSanitizer
{
public static byte[] PrepareForGodot(byte[] wavBytes)
{
ReadOnlySpan<byte> input = wavBytes;
if (input.Length < 12 || !HasId(input, 0, "RIFF") || !HasId(input, 8, "WAVE"))
return wavBytes;
ulong declaredEnd64 = 8UL + BinaryPrimitives.ReadUInt32LittleEndian(input.Slice(4, 4));
if (declaredEnd64 < 12 || declaredEnd64 > (ulong)input.Length || declaredEnd64 > int.MaxValue)
return wavBytes;
int declaredEnd = (int)declaredEnd64;
int cursor = 12;
int removedBytes = 0;
while (cursor < declaredEnd)
{
if (!TryGetChunk(input, cursor, declaredEnd, out int chunkBytes, out bool isInfoList))
return wavBytes;
if (isInfoList) removedBytes = checked(removedBytes + chunkBytes);
cursor += chunkBytes;
}
if (removedBytes == 0 && declaredEnd == wavBytes.Length) return wavBytes;
int newDeclaredEnd = checked(declaredEnd - removedBytes);
var output = new byte[newDeclaredEnd];
input[..12].CopyTo(output);
cursor = 12;
int destination = 12;
while (cursor < declaredEnd)
{
_ = TryGetChunk(input, cursor, declaredEnd, out int chunkBytes, out bool isInfoList);
if (!isInfoList)
{
input.Slice(cursor, chunkBytes).CopyTo(output.AsSpan(destination));
destination += chunkBytes;
}
cursor += chunkBytes;
}
BinaryPrimitives.WriteUInt32LittleEndian(output.AsSpan(4, 4),
checked((uint)(newDeclaredEnd - 8)));
return output;
}
private static bool TryGetChunk(ReadOnlySpan<byte> input, int offset, int declaredEnd,
out int chunkBytes, out bool isInfoList)
{
chunkBytes = 0;
isInfoList = false;
if (offset > declaredEnd - 8) return false;
uint payloadBytes = BinaryPrimitives.ReadUInt32LittleEndian(input.Slice(offset + 4, 4));
ulong total64 = 8UL + payloadBytes + (payloadBytes & 1U);
if (total64 > int.MaxValue || total64 > (ulong)(declaredEnd - offset)) return false;
chunkBytes = (int)total64;
isInfoList = payloadBytes >= 4 && HasId(input, offset, "LIST")
&& HasId(input, offset + 8, "INFO");
return true;
}
private static bool HasId(ReadOnlySpan<byte> bytes, int offset, string id)
=> offset >= 0 && offset <= bytes.Length - 4
&& bytes[offset] == id[0] && bytes[offset + 1] == id[1]
&& bytes[offset + 2] == id[2] && bytes[offset + 3] == id[3];
}