Move movie runtime support to frontend
This commit is contained in:
35
engine/Age.Engine.Frontend/IMovieDecoder.cs
Normal file
35
engine/Age.Engine.Frontend/IMovieDecoder.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
}
|
||||
50
engine/Age.Engine.Frontend/MovieAudioTimeline.cs
Normal file
50
engine/Age.Engine.Frontend/MovieAudioTimeline.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
35
engine/Age.Engine.Frontend/MovieRuntime.cs
Normal file
35
engine/Age.Engine.Frontend/MovieRuntime.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
184
engine/Age.Engine.Frontend/MovieSurfaceRegistry.cs
Normal file
184
engine/Age.Engine.Frontend/MovieSurfaceRegistry.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
4
engine/Age.Engine.Frontend/Properties/AssemblyInfo.cs
Normal file
4
engine/Age.Engine.Frontend/Properties/AssemblyInfo.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
[assembly: InternalsVisibleTo("Age.Engine.Tests")]
|
||||
[assembly: InternalsVisibleTo("Himegari")]
|
||||
72
engine/Age.Engine.Frontend/RiffWaveSanitizer.cs
Normal file
72
engine/Age.Engine.Frontend/RiffWaveSanitizer.cs
Normal file
@@ -0,0 +1,72 @@
|
||||
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];
|
||||
}
|
||||
@@ -24,13 +24,8 @@
|
||||
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
|
||||
<ProjectReference Include="..\Age.Engine.Frontend\Age.Engine.Frontend.csproj" />
|
||||
<ProjectReference Include="..\Age.Engine.Text.Windows\Age.Engine.Text.Windows.csproj" />
|
||||
<Compile Include="..\..\godot\IMovieDecoder.cs" Link="IMovieDecoder.cs" />
|
||||
<Compile Include="..\..\godot\MovieRuntime.cs" Link="MovieRuntime.cs" />
|
||||
<Compile Include="..\..\godot\MovieAudioTimeline.cs" Link="MovieAudioTimeline.cs" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
|
||||
<Compile Include="..\..\godot\FfmpegMovieDecoder.cs" Link="FfmpegMovieDecoder.cs" />
|
||||
<Compile Include="..\..\godot\MovieSurfaceRegistry.cs" Link="MovieSurfaceRegistry.cs" />
|
||||
<Compile Include="..\..\godot\RiffWaveSanitizer.cs" Link="RiffWaveSanitizer.cs" />
|
||||
<Compile Include="..\..\tools\movie-corpus-gate\MovieCorpusGate.cs" Link="MovieCorpusGate.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user