Add synchronized MPEG movie audio

This commit is contained in:
gamer147
2026-07-25 11:49:50 -04:00
parent 1828701872
commit 843b282cc4
24 changed files with 1336 additions and 558 deletions

View File

@@ -1,304 +0,0 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Threading;
using Age.Engine.Sys4;
[ComVisible(true), Guid("0579154A-2B53-4994-B0D0-E773148EFF85"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
internal interface ISampleGrabberCB
{
[PreserveSig] int SampleCB(double time, IntPtr sample);
[PreserveSig] int BufferCB(double time, IntPtr buffer, int length);
}
/// <summary>
/// Windows DirectShow bridge for AGE's MPEG program-stream movie payloads. The payload is supplied by
/// IAssetStore; a private temporary file only adapts those owned bytes to DirectShow's stock MPEG source.
/// Decoded RGB32 samples are copied into process memory and consumed by Godot's retained compositor.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB
{
private readonly byte[] _payload;
private readonly Thread _thread;
private readonly ManualResetEventSlim _ready = new(false);
private readonly object _frameLock = new();
private byte[]? _latestRgba;
private int _width, _height;
private bool _bottomUp;
private volatile bool _stopping;
private volatile bool _completed;
private string? _error;
private string? _tempPath;
private object? _graphObject;
private IMediaControl? _control;
public bool IsCompleted => _completed;
public string? Failure => _error;
/// <summary>The graph's IMediaPosition stop time converted exactly as native op 0x23f does:
/// seconds * 1000, truncated toward zero. Null means DirectShow supplied no usable value.</summary>
public long? StopTimeMs { get; private set; }
public DirectShowMovieDecoder(MoviePayload movie)
{
_payload = movie.Bytes;
_thread = new Thread(DecodeThread) { IsBackground = true, Name = $"AGE movie {movie.Name}" };
_thread.SetApartmentState(ApartmentState.MTA);
_thread.Start();
// Native 0x236 builds the graph synchronously. Preserve that initialization boundary while keeping
// actual playback asynchronous; timeout becomes a deterministic load error rather than a VM hang.
if (!_ready.Wait(TimeSpan.FromSeconds(10))) throw new InvalidOperationException("movie graph initialization timed out");
if (_error != null) throw new InvalidOperationException(_error);
}
public bool TryTakeFrame(out RgbaImage frame)
{
lock (_frameLock)
{
if (_latestRgba == null) { frame = default!; return false; }
frame = new RgbaImage(_width, _height, _latestRgba);
_latestRgba = null;
return true;
}
}
private void DecodeThread()
{
int co = CoInitializeEx(IntPtr.Zero, 0); // COINIT_MULTITHREADED; DirectShow graph and callbacks share this apartment.
try
{
_tempPath = Path.Combine(Path.GetTempPath(), $"age-movie-{Guid.NewGuid():N}.mpg");
File.WriteAllBytes(_tempPath, _payload);
_graphObject = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_FilterGraph, throwOnError: true)!)!;
var graph = (IGraphBuilder)_graphObject;
var grabberObject = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_SampleGrabber, true)!)!;
var nullObject = Activator.CreateInstance(Type.GetTypeFromCLSID(CLSID_NullRenderer, true)!)!;
var grabberFilter = (IBaseFilter)grabberObject;
var nullFilter = (IBaseFilter)nullObject;
var grabber = (ISampleGrabber)grabberObject;
Check(graph.AddFilter(grabberFilter, "AGE Sample Grabber"), "add sample grabber");
Check(graph.AddFilter(nullFilter, "AGE Null Renderer"), "add null renderer");
var requested = new AMMediaType { MajorType = MEDIATYPE_Video, SubType = MEDIASUBTYPE_RGB32,
FormatType = FORMAT_VideoInfo };
Check(grabber.SetMediaType(ref requested), "request RGB32 movie output");
Check(graph.AddSourceFilter(_tempPath, "AGE VFS MPEG Source", out var source), "open MPEG source");
var sourceOut = FirstPin(source, PinDirection.Output);
var grabberIn = FirstPin(grabberFilter, PinDirection.Input);
var grabberOut = FirstPin(grabberFilter, PinDirection.Output);
var nullIn = FirstPin(nullFilter, PinDirection.Input);
// Intelligent connection inserts only the MPEG splitter/video decoder needed to reach RGB32.
// The splitter's audio pin remains unrendered, keeping movie audio outside this slice.
Check(graph.Connect(sourceOut, grabberIn), "connect MPEG video decoder");
Check(graph.ConnectDirect(grabberOut, nullIn, IntPtr.Zero), "connect null renderer");
var connected = new AMMediaType();
Check(grabber.GetConnectedMediaType(ref connected), "query movie format");
try
{
if (connected.FormatPtr == IntPtr.Zero) throw new InvalidDataException("movie decoder returned no VIDEOINFOHEADER");
var vi = Marshal.PtrToStructure<VideoInfoHeader>(connected.FormatPtr);
_width = vi.BitmapInfo.Width;
_bottomUp = vi.BitmapInfo.Height > 0;
_height = Math.Abs(vi.BitmapInfo.Height);
if (_width <= 0 || _height <= 0) throw new InvalidDataException($"invalid movie dimensions {_width}x{_height}");
}
finally { FreeMediaType(ref connected); }
Check(grabber.SetOneShot(false), "configure continuous samples");
Check(grabber.SetBufferSamples(false), "disable redundant frame buffering");
Check(grabber.SetCallback(this, 1), "install decoded frame callback");
_control = (IMediaControl)_graphObject;
Check(_control.Run(), "start movie graph");
int stateHr = _control.GetState(5000, out int graphState);
if (stateHr < 0) Check(stateHr, "wait for running movie graph");
if (graphState != 2) throw new InvalidOperationException($"movie graph entered unexpected state {graphState}");
var mediaPosition = (IMediaPosition)_graphObject;
int stopHr = mediaPosition.get_StopTime(out double stopTimeSeconds);
double stopTimeMilliseconds = stopTimeSeconds * 1000.0;
if (stopHr >= 0 && double.IsFinite(stopTimeMilliseconds)
&& stopTimeMilliseconds >= int.MinValue
&& stopTimeMilliseconds <= int.MaxValue)
StopTimeMs = (long)System.Math.Truncate(stopTimeMilliseconds);
_ready.Set();
var mediaEvent = (IMediaEvent)_graphObject;
while (!_stopping)
{
int eventHr = mediaEvent.WaitForCompletion(0, out _);
if (eventHr >= 0) { _completed = true; break; }
if (eventHr != E_ABORT) Check(eventHr, "poll movie completion");
Thread.Sleep(10);
}
}
catch (Exception e)
{
_error = $"DirectShow MPEG decode failed: {e}";
_ready.Set();
}
finally
{
try { _control?.Stop(); } catch { }
ReleaseCom(_control); _control = null;
ReleaseCom(_graphObject); _graphObject = null;
if (_tempPath != null) try { File.Delete(_tempPath); } catch { }
if (co >= 0) CoUninitialize();
}
}
public int SampleCB(double sampleTime, IntPtr sample) => 0;
public int BufferCB(double sampleTime, IntPtr buffer, int length)
{ AcceptBgra(buffer, length); return 0; }
private void AcceptBgra(IntPtr buffer, int length)
{
int rowBytes = checked(_width * 4);
if (buffer == IntPtr.Zero || length < rowBytes * _height) return;
byte[] bgra = new byte[rowBytes * _height];
Marshal.Copy(buffer, bgra, 0, bgra.Length);
byte[] rgba = new byte[bgra.Length];
for (int y = 0; y < _height; y++)
{
int src = (_bottomUp ? _height - 1 - y : y) * rowBytes;
int dst = y * rowBytes;
for (int x = 0; x < _width; x++, src += 4, dst += 4)
{
rgba[dst] = bgra[src + 2]; rgba[dst + 1] = bgra[src + 1];
rgba[dst + 2] = bgra[src]; rgba[dst + 3] = 255;
}
}
lock (_frameLock) _latestRgba = rgba; // newest decoded frame wins if Godot renders more slowly
}
public void Dispose()
{
_stopping = true;
if (_thread.IsAlive) _thread.Join(TimeSpan.FromSeconds(3));
_ready.Dispose();
}
private static IPin FirstPin(IBaseFilter filter, PinDirection direction)
{
Check(filter.EnumPins(out var pins), "enumerate source pins");
var one = new IPin[1];
while (pins.Next(1, one, IntPtr.Zero) == 0)
{
Check(one[0].QueryDirection(out var found), "query source pin direction");
if (found == direction) return one[0];
}
throw new InvalidOperationException($"DirectShow source has no {direction} pin");
}
private static void Check(int hr, string operation)
{ if (hr < 0) throw new COMException($"{operation} failed (HRESULT 0x{hr:x8})", hr); }
private static void ReleaseCom(object? value)
{ if (value != null && Marshal.IsComObject(value)) try { Marshal.ReleaseComObject(value); } catch { } }
private static void FreeMediaType(ref AMMediaType mt)
{
if (mt.FormatPtr != IntPtr.Zero) { Marshal.FreeCoTaskMem(mt.FormatPtr); mt.FormatPtr = IntPtr.Zero; }
if (mt.UnknownPtr != IntPtr.Zero) { Marshal.Release(mt.UnknownPtr); mt.UnknownPtr = IntPtr.Zero; }
}
private static readonly Guid CLSID_FilterGraph = new("E436EBB3-524F-11CE-9F53-0020AF0BA770");
private static readonly Guid CLSID_SampleGrabber = new("C1F400A0-3F08-11D3-9F0B-006008039E37");
private static readonly Guid CLSID_NullRenderer = new("C1F400A4-3F08-11D3-9F0B-006008039E37");
private static readonly Guid MEDIATYPE_Video = new("73646976-0000-0010-8000-00AA00389B71");
private static readonly Guid MEDIASUBTYPE_RGB32 = new("E436EB7E-524F-11CE-9F53-0020AF0BA770");
private static readonly Guid FORMAT_VideoInfo = new("05589F80-C356-11CE-BF01-00AA0055595A");
private const int E_ABORT = unchecked((int)0x80004004);
[DllImport("ole32.dll")] private static extern int CoInitializeEx(IntPtr reserved, uint coInit);
[DllImport("ole32.dll")] private static extern void CoUninitialize();
private enum PinDirection { Input, Output }
[StructLayout(LayoutKind.Sequential)] private struct AMMediaType
{
public Guid MajorType, SubType; [MarshalAs(UnmanagedType.Bool)] public bool FixedSizeSamples;
[MarshalAs(UnmanagedType.Bool)] public bool TemporalCompression; public int SampleSize;
public Guid FormatType; public IntPtr UnknownPtr; public int FormatSize; public IntPtr FormatPtr;
}
[StructLayout(LayoutKind.Sequential)] private struct DsRect { public int Left, Top, Right, Bottom; }
[StructLayout(LayoutKind.Sequential)] private struct BitmapInfoHeader
{ public int Size, Width, Height; public short Planes, BitCount; public int Compression, ImageSize, XPelsPerMeter, YPelsPerMeter, ColorsUsed, ColorsImportant; }
[StructLayout(LayoutKind.Sequential)] private struct VideoInfoHeader
{ public DsRect Source, Target; public int BitRate, BitErrorRate; public long AvgTimePerFrame; public BitmapInfoHeader BitmapInfo; }
[ComImport, Guid("6B652FFF-11FE-4FCE-92AD-0266B5D7C78F"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface ISampleGrabber
{
[PreserveSig] int SetOneShot([MarshalAs(UnmanagedType.Bool)] bool value);
[PreserveSig] int SetMediaType(ref AMMediaType type);
[PreserveSig] int GetConnectedMediaType(ref AMMediaType type);
[PreserveSig] int SetBufferSamples([MarshalAs(UnmanagedType.Bool)] bool value);
[PreserveSig] int GetCurrentBuffer(ref int size, IntPtr buffer);
[PreserveSig] int GetCurrentSample(out IntPtr sample);
[PreserveSig] int SetCallback(ISampleGrabberCB callback, int method);
}
[ComImport, Guid("56A868A9-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IGraphBuilder
{
[PreserveSig] int AddFilter(IBaseFilter filter, [MarshalAs(UnmanagedType.LPWStr)] string name);
[PreserveSig] int RemoveFilter(IBaseFilter filter); [PreserveSig] int EnumFilters(out IntPtr filters);
[PreserveSig] int FindFilterByName([MarshalAs(UnmanagedType.LPWStr)] string name, out IBaseFilter filter);
[PreserveSig] int ConnectDirect(IPin output, IPin input, IntPtr mediaType);
[PreserveSig] int Reconnect(IPin pin); [PreserveSig] int Disconnect(IPin pin); [PreserveSig] int SetDefaultSyncSource();
[PreserveSig] int Connect(IPin output, IPin input); [PreserveSig] int Render(IPin output);
[PreserveSig] int RenderFile([MarshalAs(UnmanagedType.LPWStr)] string file, [MarshalAs(UnmanagedType.LPWStr)] string? playList);
[PreserveSig] int AddSourceFilter([MarshalAs(UnmanagedType.LPWStr)] string file, [MarshalAs(UnmanagedType.LPWStr)] string name, out IBaseFilter filter);
[PreserveSig] int SetLogFile(IntPtr file); [PreserveSig] int Abort(); [PreserveSig] int ShouldOperationContinue();
}
[ComImport, Guid("56A86895-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IBaseFilter
{
[PreserveSig] int GetClassID(out Guid clsid); [PreserveSig] int Stop(); [PreserveSig] int Pause();
[PreserveSig] int Run(long start); [PreserveSig] int GetState(int timeout, out int state);
[PreserveSig] int SetSyncSource(IntPtr clock); [PreserveSig] int GetSyncSource(out IntPtr clock);
[PreserveSig] int EnumPins(out IEnumPins pins); [PreserveSig] int FindPin([MarshalAs(UnmanagedType.LPWStr)] string id, out IPin pin);
[PreserveSig] int QueryFilterInfo(IntPtr info); [PreserveSig] int JoinFilterGraph(IntPtr graph, [MarshalAs(UnmanagedType.LPWStr)] string name);
[PreserveSig] int QueryVendorInfo([MarshalAs(UnmanagedType.LPWStr)] out string vendor);
}
[ComImport, Guid("56A86892-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IEnumPins
{ [PreserveSig] int Next(int count, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0)] IPin[] pins, IntPtr fetched); [PreserveSig] int Skip(int count); [PreserveSig] int Reset(); [PreserveSig] int Clone(out IEnumPins clone); }
[ComImport, Guid("56A86891-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IPin
{
[PreserveSig] int Connect(IPin receive, IntPtr mediaType); [PreserveSig] int ReceiveConnection(IPin connector, IntPtr mediaType);
[PreserveSig] int Disconnect(); [PreserveSig] int ConnectedTo(out IPin pin); [PreserveSig] int ConnectionMediaType(IntPtr mediaType);
[PreserveSig] int QueryPinInfo(IntPtr info); [PreserveSig] int QueryDirection(out PinDirection direction);
[PreserveSig] int QueryId([MarshalAs(UnmanagedType.LPWStr)] out string id); [PreserveSig] int QueryAccept(IntPtr mediaType);
[PreserveSig] int EnumMediaTypes(out IntPtr types); [PreserveSig] int QueryInternalConnections(IntPtr pins, ref int count);
[PreserveSig] int EndOfStream(); [PreserveSig] int BeginFlush(); [PreserveSig] int EndFlush(); [PreserveSig] int NewSegment(long start, long stop, double rate);
}
[ComImport, Guid("56A868B1-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
private interface IMediaControl
{ [PreserveSig] int Run(); [PreserveSig] int Pause(); [PreserveSig] int Stop(); [PreserveSig] int GetState(int timeout, out int state); }
[ComImport, Guid("56A868B2-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
private interface IMediaPosition
{
[PreserveSig] int get_Duration(out double seconds);
[PreserveSig] int put_CurrentPosition(double seconds);
[PreserveSig] int get_CurrentPosition(out double seconds);
[PreserveSig] int get_StopTime(out double seconds);
[PreserveSig] int put_StopTime(double seconds);
[PreserveSig] int get_PrerollTime(out double seconds);
[PreserveSig] int put_PrerollTime(double seconds);
[PreserveSig] int put_Rate(double rate);
[PreserveSig] int get_Rate(out double rate);
[PreserveSig] int CanSeekForward(out int canSeekForward);
[PreserveSig] int CanSeekBackward(out int canSeekBackward);
}
[ComImport, Guid("56A868B6-0AD4-11CE-B03A-0020AF0BA770"), InterfaceType(ComInterfaceType.InterfaceIsDual)]
private interface IMediaEvent
{
[PreserveSig] int GetEventHandle(out IntPtr eventHandle);
[PreserveSig] int GetEvent(out int eventCode, out IntPtr param1, out IntPtr param2, int timeoutMs);
[PreserveSig] int WaitForCompletion(int timeoutMs, out int eventCode);
[PreserveSig] int CancelDefaultHandling(int eventCode);
[PreserveSig] int RestoreDefaultHandling(int eventCode);
[PreserveSig] int FreeEventParams(int eventCode, IntPtr param1, IntPtr param2);
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Collections.Generic;
using System.Threading;
using Age.Engine.Sys4;
@@ -9,6 +10,11 @@ internal interface IMoviePacingClock
bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation);
}
internal interface IExternallyAdvancedMoviePacingClock : IMoviePacingClock
{
void AdvanceTo(long elapsedMilliseconds);
}
internal sealed class StopwatchMoviePacingClock : IMoviePacingClock
{
private readonly long _startedAt = Stopwatch.GetTimestamp();
@@ -26,9 +32,39 @@ internal sealed class StopwatchMoviePacingClock : IMoviePacingClock
}
}
internal sealed class ExternallyAdvancedMoviePacingClock : IExternallyAdvancedMoviePacingClock, IDisposable
{
private readonly AutoResetEvent _advanced = new(false);
private long _now;
public bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation)
{
while (Interlocked.Read(ref _now) < elapsedMilliseconds)
{
int signalled = WaitHandle.WaitAny([cancellation, _advanced]);
if (signalled == 0) return false;
}
return true;
}
public void AdvanceTo(long elapsedMilliseconds)
{
long current;
do
{
current = Interlocked.Read(ref _now);
if (elapsedMilliseconds <= current) return;
}
while (Interlocked.CompareExchange(ref _now, elapsedMilliseconds, current) != current);
_advanced.Set();
}
public void Dispose() => _advanced.Dispose();
}
/// <summary>
/// Timestamp-paced FFmpeg video delivery. The worker decodes no more than one frame ahead, publishes only
/// when its presentation timestamp is due, and reports completion after the final presentation interval.
/// Timestamp-paced FFmpeg delivery. Video-only streams retain monotonic stopwatch pacing. Audio-bearing
/// streams decode PCM into a bounded queue and advance video against the sound-device clock supplied by Godot.
/// </summary>
internal sealed class FfmpegMovieDecoder : IMovieDecoder
{
@@ -36,33 +72,70 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
private readonly IMoviePacingClock _clock;
private readonly ManualResetEvent _cancel = new(false);
private readonly Thread _thread;
private readonly Thread? _audioThread;
private readonly object _frameLock = new();
private readonly object _audioLock = new();
private readonly Queue<MovieAudioChunk> _audioChunks = new();
private readonly AutoResetEvent _audioSpace = new(false);
private readonly int _maximumQueuedAudioFrames;
private RgbaImage? _latestFrame;
private volatile bool _completed;
private volatile bool _videoTimelineCompleted;
private volatile bool _audioDecodingCompleted;
private volatile bool _audioSubmitted;
private string? _failure;
private int _queuedAudioFrames;
private int _activeWorkers;
private int _disposed;
public long? StopTimeMs => _source.Info.StopTimeMs;
public bool IsCompleted => _completed;
public string? Failure => Volatile.Read(ref _failure);
public MovieAudioInfo? AudioInfo { get; }
public bool AudioDecodingCompleted => _audioDecodingCompleted;
public FfmpegMovieDecoder(MoviePayload movie)
: this(new FfmpegMovieSession(movie), new StopwatchMoviePacingClock()) { }
: this(new FfmpegMovieSession(movie), null) { }
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock clock)
internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock? clock)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_clock = clock ?? (source.Info.HasAudio
? new ExternallyAdvancedMoviePacingClock()
: new StopwatchMoviePacingClock());
if (source.Info.HasAudio)
{
AudioInfo = new MovieAudioInfo(source.Info.AudioSampleRate, source.Info.AudioChannels);
_maximumQueuedAudioFrames = Math.Max(source.Info.AudioSampleRate / 2,
source.Info.AudioFrameSamples * 2);
}
_thread = new Thread(DecodeThread)
{
IsBackground = true,
Name = "AGE FFmpeg movie",
Name = "AGE FFmpeg movie video",
};
try { _thread.Start(); }
_audioThread = source.Info.HasAudio
? new Thread(AudioDecodeThread)
{
IsBackground = true,
Name = "AGE FFmpeg movie audio",
}
: null;
_activeWorkers = _audioThread == null ? 1 : 2;
try
{
_thread.Start();
_audioThread?.Start();
}
catch
{
_cancel.Set();
if (_thread.IsAlive) _thread.Join();
if (_audioThread?.IsAlive == true) _audioThread.Join();
_source.Dispose();
_cancel.Dispose();
_audioSpace.Dispose();
if (_clock is IDisposable disposableClock) disposableClock.Dispose();
throw;
}
}
@@ -82,6 +155,34 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
}
}
public bool TryTakeAudioChunk(out MovieAudioChunk chunk)
{
lock (_audioLock)
{
if (_audioChunks.Count == 0)
{
chunk = default!;
return false;
}
chunk = _audioChunks.Dequeue();
_queuedAudioFrames -= chunk.FrameCount;
}
_audioSpace.Set();
return true;
}
public void AdvancePlaybackClock(long elapsedMilliseconds)
{
if (_clock is IExternallyAdvancedMoviePacingClock external)
external.AdvanceTo(Math.Max(0, elapsedMilliseconds));
}
public void MarkAudioSubmitted()
{
_audioSubmitted = true;
UpdateCompletion();
}
private void DecodeThread()
{
try
@@ -96,7 +197,11 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
throw new InvalidDataException("FFmpeg stream ended before producing a video frame");
long completionTime = Math.Max(_source.Info.StopTimeMs,
lastTimestamp + FrameIntervalMilliseconds(_source.Info));
if (_clock.WaitUntil(completionTime, _cancel)) _completed = true;
if (_clock.WaitUntil(completionTime, _cancel))
{
_videoTimelineCompleted = true;
UpdateCompletion();
}
return;
}
if (frame.PresentationTimeMs < 0 || frame.PresentationTimeMs < lastTimestamp)
@@ -110,15 +215,79 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
}
catch (Exception error)
{
Volatile.Write(ref _failure, error.Message);
_completed = true; // decode failure must never strand an AGE movie wait
Fail(error);
}
finally
{
_source.Dispose();
WorkerCompleted();
}
}
private void AudioDecodeThread()
{
try
{
long priorTimestamp = -1;
while (!_cancel.WaitOne(0))
{
while (Volatile.Read(ref _queuedAudioFrames) >= _maximumQueuedAudioFrames)
{
int signalled = WaitHandle.WaitAny([_cancel, _audioSpace]);
if (signalled == 0) return;
}
if (!_source.TryDecodeNextAudioChunk(out FfmpegAudioChunk decoded))
{
_audioDecodingCompleted = true;
UpdateCompletion();
return;
}
if (decoded.FrameCount <= 0
|| decoded.InterleavedStereo.Length != checked(decoded.FrameCount * 2)
|| decoded.PresentationTimeMs < 0
|| decoded.PresentationTimeMs < priorTimestamp)
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);
lock (_audioLock)
{
_audioChunks.Enqueue(chunk);
_queuedAudioFrames += chunk.FrameCount;
}
priorTimestamp = decoded.PresentationTimeMs;
}
}
catch (Exception error)
{
Fail(error);
}
finally
{
WorkerCompleted();
}
}
private void Fail(Exception error)
{
Interlocked.CompareExchange(ref _failure, error.Message, null);
_completed = true; // decode failure must never strand an AGE movie wait
_cancel.Set();
_audioSpace.Set();
}
private void UpdateCompletion()
{
if (_failure != null || !_videoTimelineCompleted) return;
if (AudioInfo != null && (!_audioDecodingCompleted || !_audioSubmitted)) return;
_completed = true;
}
private void WorkerCompleted()
{
if (Interlocked.Decrement(ref _activeWorkers) == 0) _source.Dispose();
}
private static long FrameIntervalMilliseconds(FfmpegMovieInfo info)
{
if (info.FrameRateNumerator <= 0 || info.FrameRateDenominator <= 0) return 0;
@@ -130,9 +299,14 @@ internal sealed class FfmpegMovieDecoder : IMovieDecoder
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_cancel.Set();
_audioSpace.Set();
if (_thread.IsAlive && Thread.CurrentThread != _thread)
_thread.Join();
if (_audioThread?.IsAlive == true && Thread.CurrentThread != _audioThread)
_audioThread.Join();
_cancel.Dispose();
_audioSpace.Dispose();
if (_clock is IDisposable disposableClock) disposableClock.Dispose();
}
}

View File

@@ -10,26 +10,36 @@ internal readonly record struct FfmpegMovieInfo(
long StopTimeMs,
int FrameRateNumerator,
int FrameRateDenominator,
bool HasAudio);
bool HasAudio,
int AudioSampleRate = 0,
int AudioChannels = 0,
int AudioFrameSamples = 0);
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
internal sealed record FfmpegAudioChunk(float[] InterleavedStereo, int FrameCount, long PresentationTimeMs);
internal interface IFfmpegFrameSource : IDisposable
{
FfmpegMovieInfo Info { get; }
bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame);
bool TryDecodeNextAudioChunk(out FfmpegAudioChunk chunk)
{
chunk = default!;
return false;
}
}
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
{
private FfmpegMovieHandle _handle;
private readonly object _decodeLock = new();
public FfmpegMovieInfo Info { get; }
public FfmpegMovieSession(MoviePayload movie)
{
ArgumentNullException.ThrowIfNull(movie);
if (FfmpegMovieNative.AbiVersion() != 1)
if (FfmpegMovieNative.AbiVersion() != 2)
throw new InvalidOperationException("unsupported age_movie_ffmpeg ABI version");
byte[] error = new byte[1024];
@@ -42,31 +52,97 @@ internal sealed class FfmpegMovieSession : IFfmpegFrameSource
$"FFmpeg movie open failed for {movie.Name}: {FfmpegMovieNative.DecodeUtf8(error)}");
}
Info = new FfmpegMovieInfo(nativeInfo.Width, nativeInfo.Height, nativeInfo.StopTimeMs,
nativeInfo.FrameRateNumerator, nativeInfo.FrameRateDenominator, nativeInfo.HasAudio != 0);
nativeInfo.FrameRateNumerator, nativeInfo.FrameRateDenominator, nativeInfo.HasAudio != 0,
nativeInfo.AudioSampleRate, nativeInfo.AudioChannels, nativeInfo.AudioFrameSamples);
if (Info.Width <= 0 || Info.Height <= 0 || Info.StopTimeMs <= 0)
{
_handle.Dispose();
throw new InvalidDataException(
$"FFmpeg returned invalid metadata for {movie.Name}: {Info.Width}x{Info.Height}, {Info.StopTimeMs} ms");
}
if (Info.HasAudio && (Info.AudioSampleRate <= 0 || Info.AudioChannels != 2
|| Info.AudioFrameSamples <= 0))
{
_handle.Dispose();
throw new InvalidDataException(
$"FFmpeg returned invalid audio metadata for {movie.Name}: " +
$"{Info.AudioSampleRate} Hz, {Info.AudioChannels} channels, {Info.AudioFrameSamples} frames");
}
}
public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame)
{
ObjectDisposedException.ThrowIf(_handle.IsClosed, this);
byte[] pixels = new byte[checked(Info.Width * Info.Height * 4)];
int result = FfmpegMovieNative.DecodeVideo(_handle, pixels, (nuint)pixels.Length, out long ptsMs);
int result;
long ptsMs;
string? error = null;
lock (_decodeLock)
{
result = FfmpegMovieNative.DecodeVideo(
_handle, pixels, (nuint)pixels.Length, out ptsMs);
if (result != FfmpegMovieNative.EndOfFile && result != FfmpegMovieNative.Frame)
error = FfmpegMovieNative.LastError(_handle);
}
if (result == FfmpegMovieNative.EndOfFile)
{
frame = default!;
return false;
}
if (result != FfmpegMovieNative.Frame)
throw new InvalidDataException($"FFmpeg movie decode failed: {FfmpegMovieNative.LastError(_handle)}");
throw new InvalidDataException($"FFmpeg movie decode failed: {error}");
frame = new FfmpegVideoFrame(new RgbaImage(Info.Width, Info.Height, pixels), ptsMs);
return true;
}
public bool TryDecodeNextAudioChunk(out FfmpegAudioChunk chunk)
{
ObjectDisposedException.ThrowIf(_handle.IsClosed, this);
if (!Info.HasAudio)
{
chunk = default!;
return false;
}
int capacity = Math.Max(Info.AudioFrameSamples, 1);
while (true)
{
float[] samples = new float[checked(capacity * 2)];
int result;
int frameCount;
long ptsMs;
string? error = null;
lock (_decodeLock)
{
result = FfmpegMovieNative.DecodeAudio(
_handle, samples, (nuint)capacity, out frameCount, out ptsMs);
if (result != FfmpegMovieNative.EndOfFile
&& result != FfmpegMovieNative.Frame
&& result != FfmpegMovieNative.BufferTooSmall)
error = FfmpegMovieNative.LastError(_handle);
}
if (result == FfmpegMovieNative.EndOfFile)
{
chunk = default!;
return false;
}
if (result == FfmpegMovieNative.BufferTooSmall)
{
if (frameCount <= capacity)
throw new InvalidDataException("FFmpeg audio decode reported an invalid required buffer size");
capacity = frameCount;
continue;
}
if (result != FfmpegMovieNative.Frame)
throw new InvalidDataException($"FFmpeg movie audio decode failed: {error}");
if (frameCount <= 0 || frameCount > capacity)
throw new InvalidDataException($"FFmpeg returned invalid audio frame count {frameCount}");
if (frameCount != capacity) Array.Resize(ref samples, checked(frameCount * 2));
chunk = new FfmpegAudioChunk(samples, frameCount, ptsMs);
return true;
}
}
public void Dispose() => _handle.Dispose();
}
@@ -84,6 +160,7 @@ internal static class FfmpegMovieNative
{
internal const int EndOfFile = 0;
internal const int Frame = 1;
internal const int BufferTooSmall = -3;
private const string LibraryName = "age_movie_ffmpeg";
[StructLayout(LayoutKind.Sequential)]
@@ -95,6 +172,9 @@ internal static class FfmpegMovieNative
public int FrameRateNumerator;
public int FrameRateDenominator;
public int HasAudio;
public int AudioSampleRate;
public int AudioChannels;
public int AudioFrameSamples;
}
static FfmpegMovieNative()
@@ -160,6 +240,14 @@ internal static class FfmpegMovieNative
nuint rgbaSize,
out long presentationTimeMs);
[DllImport(LibraryName, EntryPoint = "age_movie_decode_audio", CallingConvention = CallingConvention.Cdecl)]
internal static extern int DecodeAudio(
FfmpegMovieHandle movie,
[Out] float[] stereo,
nuint frameCapacity,
out int frameCount,
out long presentationTimeMs);
[DllImport(LibraryName, EntryPoint = "age_movie_last_error", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr LastErrorNative(FfmpegMovieHandle movie);

View File

@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.Versioning;
using System.Threading;
using Age.Engine.Hosting;
using Age.Engine.Model;
@@ -18,7 +17,6 @@ public enum HostPresentationReason
DiscreteSourceCell = 16,
}
[SupportedOSPlatform("windows")]
public sealed class GodotAdvHost : IHost
{
private readonly Main _main;
@@ -1061,8 +1059,9 @@ public sealed class GodotAdvHost : IHost
["surface"] = surfaceSlot, ["file"] = movie.Name,
["flags"] = movieFlags, ["sync_mask"] = syncMask, ["modal"] = modal,
});
bool started = _main.TryPlayMovie(movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId,
out stopTimeMs);
bool started = _main.TryPlayMovie(
movie.Bytes, movie.Name, playbackId, resourceId, asset.PackedId, movieFlags,
out stopTimeMs);
if (!started)
{
stopTimeMs = 0;

View File

@@ -1,7 +1,9 @@
using System;
using System.Runtime.Versioning;
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.
@@ -12,15 +14,18 @@ internal interface IMovieDecoder : IDisposable
bool IsCompleted { get; }
string? Failure { get; }
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);
}
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoderFactory : IMovieDecoderFactory
{
public IMovieDecoder Open(MoviePayload movie) => new DirectShowMovieDecoder(movie);
}

View File

@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Runtime.Versioning;
using System.Text.Json;
using System.Threading.Tasks;
using Godot;
@@ -13,7 +12,6 @@ using Age.Engine.Sys4;
using Age.Engine.Vm;
using Script = Age.Engine.Model.Script; // disambiguate from Godot.Script
[SupportedOSPlatform("windows")]
public partial class Main : Godot.Control
{
private const int ScreenWidth = 800;
@@ -56,10 +54,12 @@ public partial class Main : Godot.Control
private IReadOnlyList<DebugSceneEntry> _debugSceneEntries = System.Array.Empty<DebugSceneEntry>();
private readonly Age.Engine.Hosting.FrameClock _clock = new();
private readonly System.Collections.Generic.Dictionary<long, MovieRuntime> _movies = new();
private readonly System.Collections.Generic.Dictionary<long, MovieAudioOutput> _movieAudio = new();
// 0x236 opens its decoder synchronously on the VM thread so 0x23f can query timing immediately.
// Presentation ownership transfers here; _Process adopts staged decoders before sampling frames.
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, MovieRuntime> _pendingMovies = new();
private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory();
private double _audioOutputLatencySeconds;
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
private GodotTraceSink _trace = null!;
@@ -161,13 +161,18 @@ public partial class Main : Godot.Control
_bgm = new AudioStreamPlayer();
_voice = new AudioStreamPlayer();
EnsureAudioBuses();
_bgm.Bus = "Music";
_voice.Bus = "Voice";
AddChild(_bgm);
AddChild(_voice);
for (int i = 0; i < _sfx.Length; i++)
{
_sfx[i] = new AudioStreamPlayer();
_sfx[i].Bus = "SFX";
AddChild(_sfx[i]);
}
_audioOutputLatencySeconds = AudioServer.GetOutputLatency();
var userArgs = OS.GetCmdlineUserArgs();
_selftest = System.Array.IndexOf(userArgs, "--selftest") >= 0;
@@ -582,19 +587,30 @@ public partial class Main : Godot.Control
GodotTraceSnapshot trace = _trace.Snapshot();
var activeMovies = _movies
.OrderBy(pair => pair.Key)
.Select(pair => new
.Select(pair =>
{
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,
_movieAudio.TryGetValue(pair.Key, out var audio);
return 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,
audio_sample_rate = pair.Value.Decoder.AudioInfo?.SampleRate,
audio_decode_completed = pair.Value.Decoder.AudioInfo == null
? (bool?)null : pair.Value.Decoder.AudioDecodingCompleted,
audio_route = audio?.Route.ToString(),
audio_clock_ms = audio?.ClockMs,
audio_submitted_through_ms = audio?.SubmittedThroughMs,
audio_buffer_underruns = audio?.BufferUnderruns,
};
})
.ToArray();
var pendingMovies = _pendingMovies
@@ -794,6 +810,8 @@ public partial class Main : Godot.Control
GD.Print($"[perf-log] wrote {_perf.FrameCount} frames / {_perf.RecompositeCount} recomposites -> {_perf.Path}");
_perf = null;
}
foreach (var audio in _movieAudio.Values) audio.Dispose();
_movieAudio.Clear();
foreach (var movie in _pendingMovies.Values) movie.Decoder.Dispose();
_pendingMovies.Clear();
foreach (var movie in _movies.Values) movie.Decoder.Dispose();
@@ -1662,14 +1680,15 @@ public partial class Main : Godot.Control
}
public bool TryPlayMovie(byte[] mpegBytes, string assetName, long playbackId,
long resourceId, int assetId,
long resourceId, int assetId, long movieFlags,
out long? stopTimeMs)
{
stopTimeMs = null;
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = MovieRuntime.Open(assetName, assetId, resourceId, payload, _movieDecoderFactory);
var runtime = MovieRuntime.Open(
assetName, assetId, resourceId, payload, _movieDecoderFactory, movieFlags);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(playbackId, runtime))
if (_pendingMovies.TryRemove(playbackId, out var prior)) prior.Decoder.Dispose();
@@ -1689,9 +1708,19 @@ public partial class Main : Godot.Control
if (!_pendingMovies.TryRemove(playbackId, out var movie)) continue;
if (_movies.Remove(playbackId, out var prior)) prior.Decoder.Dispose();
_movies[playbackId] = movie;
if (movie.Decoder.AudioInfo != null)
{
if (_movieAudio.Remove(playbackId, out var priorAudio)) priorAudio.Dispose();
_movieAudio[playbackId] = new MovieAudioOutput(
this, movie.Decoder, MovieAudioRouteFromFlags(movie.MovieFlags),
_audioOutputLatencySeconds);
}
_movieCompletionNotified.Remove(playbackId);
GD.Print($"movie started {movie.Name} playback={playbackId} " +
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS)");
$"({movie.Decoder.StopTimeMs?.ToString() ?? "unknown"} ms from VFS" +
(movie.Decoder.AudioInfo is { } audio
? $", audio={audio.SampleRate}Hz stereo route={MovieAudioRouteFromFlags(movie.MovieFlags)}"
: "") + ")");
}
}
@@ -1700,6 +1729,7 @@ public partial class Main : Godot.Control
if (_host == null) return;
foreach (var (playbackId, movie) in _movies)
{
if (_movieAudio.TryGetValue(playbackId, out var audio)) audio.Update();
if (movie.Decoder.TryTakeFrame(out var frame))
{
_host.PublishMovieFrame(playbackId, movie.Name, movie.AssetId, frame);
@@ -1722,6 +1752,7 @@ public partial class Main : Godot.Control
public void StopMovie(long playbackId)
{
if (_movieAudio.Remove(playbackId, out var audio)) audio.Dispose();
if (_pendingMovies.TryRemove(playbackId, out var pending)) pending.Decoder.Dispose();
if (_movies.Remove(playbackId, out var movie))
{
@@ -1732,6 +1763,26 @@ public partial class Main : Godot.Control
_movieCompletionNotified.Remove(playbackId);
}
private static MovieAudioRoute MovieAudioRouteFromFlags(long flags)
{
ulong value = unchecked((ulong)flags);
if ((value & 0x10000) != 0) return MovieAudioRoute.Muted;
if ((value & 0x20000) != 0) return MovieAudioRoute.Music;
if ((value & 0x40000) != 0) return MovieAudioRoute.SoundEffect;
if ((value & 0x80000) != 0) return MovieAudioRoute.Voice;
return MovieAudioRoute.Movie;
}
private static void EnsureAudioBuses()
{
foreach (string name in new[] { "Music", "SFX", "Voice", "Movie" })
{
if (AudioServer.GetBusIndex(name) >= 0) continue;
AudioServer.AddBus();
AudioServer.SetBusName(AudioServer.BusCount - 1, name);
}
}
public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak()
{

182
godot/MovieAudioOutput.cs Normal file
View File

@@ -0,0 +1,182 @@
using System;
using Godot;
internal enum MovieAudioRoute
{
Muted = 0,
Music = 1,
SoundEffect = 2,
Voice = 3,
Movie = 4,
}
/// <summary>Godot-main-thread PCM sink and sound-hardware clock for one movie playback.</summary>
internal sealed class MovieAudioOutput : IDisposable
{
private const int MaximumPushFrames = 4096;
private readonly IMovieDecoder _decoder;
private readonly AudioStreamPlayer _player;
private readonly int _sampleRate;
private readonly double _outputLatencySeconds;
private AudioStreamGeneratorPlayback? _playback;
private MovieAudioChunk? _pending;
private int _pendingOffsetFrames;
private long _pendingGapFrames;
private long _submittedFrames;
private long _clockMs;
private bool _timelineAnchored;
private bool _started;
private bool _prerolling;
private bool _submissionCompleted;
private bool _disposed;
public MovieAudioRoute Route { get; }
public long ClockMs => _clockMs;
public long SubmittedThroughMs => _sampleRate <= 0 ? 0 : _submittedFrames * 1000 / _sampleRate;
public int BufferUnderruns => _playback?.GetSkips() ?? 0;
public MovieAudioOutput(Node owner, IMovieDecoder decoder, MovieAudioRoute route,
double outputLatencySeconds)
{
ArgumentNullException.ThrowIfNull(owner);
_decoder = decoder ?? throw new ArgumentNullException(nameof(decoder));
MovieAudioInfo info = decoder.AudioInfo
?? throw new ArgumentException("movie decoder has no audio stream", nameof(decoder));
if (info.SampleRate <= 0 || info.Channels != 2)
throw new ArgumentOutOfRangeException(nameof(decoder), "movie PCM must be stereo with a positive sample rate");
Route = route;
_sampleRate = info.SampleRate;
_outputLatencySeconds = Math.Max(0, outputLatencySeconds);
var generator = new AudioStreamGenerator
{
MixRate = _sampleRate,
BufferLength = 0.25f,
};
_player = new AudioStreamPlayer
{
Stream = generator,
Bus = RouteBus(route),
VolumeDb = route == MovieAudioRoute.Muted ? -80.0f : 0.0f,
};
owner.AddChild(_player);
}
public void Update()
{
if (_disposed) return;
if (_started)
{
double audibleSeconds = _player.GetPlaybackPosition()
+ AudioServer.GetTimeSinceLastMix()
- _outputLatencySeconds;
long candidate = (long)Math.Floor(Math.Max(0, audibleSeconds) * 1000.0);
if (candidate > _clockMs) _clockMs = candidate;
}
FeedAvailablePcm();
_decoder.AdvancePlaybackClock(_clockMs);
}
private void FeedAvailablePcm()
{
while (true)
{
if (_pending == null && !_decoder.TryTakeAudioChunk(out _pending))
{
if (_decoder.AudioDecodingCompleted && !_submissionCompleted)
{
_submissionCompleted = true;
_decoder.MarkAudioSubmitted();
}
return;
}
if (_pending == null) return;
if (!_started) Start();
if (_pendingOffsetFrames == 0 && _pendingGapFrames == 0)
{
long audibleFrame = _clockMs * _sampleRate / 1000;
if (audibleFrame > _submittedFrames) _submittedFrames = audibleFrame;
long targetFrame = MovieAudioTimeline.PresentationFrame(
_pending.PresentationTimeMs, _sampleRate);
MovieAudioAdjustment adjustment = MovieAudioTimeline.Align(
_submittedFrames, targetFrame, _pending.FrameCount, _sampleRate, _timelineAnchored);
_pendingGapFrames = adjustment.GapFrames;
_pendingOffsetFrames = adjustment.SkipFrames;
_timelineAnchored = true;
}
int available = _playback!.GetFramesAvailable();
if (available <= 0) return;
if (_pendingGapFrames > 0)
{
int count = (int)Math.Min(Math.Min(_pendingGapFrames, available), MaximumPushFrames);
if (!_playback.PushBuffer(new Vector2[count])) return;
FinishPreroll();
_pendingGapFrames -= count;
_submittedFrames += count;
continue;
}
int remaining = _pending.FrameCount - _pendingOffsetFrames;
if (remaining <= 0)
{
_pending = null;
_pendingOffsetFrames = 0;
continue;
}
int frames = Math.Min(Math.Min(remaining, available), MaximumPushFrames);
var output = new Vector2[frames];
for (int index = 0; index < frames; index++)
{
int source = checked((_pendingOffsetFrames + index) * 2);
output[index] = new Vector2(
_pending.InterleavedStereo[source],
_pending.InterleavedStereo[source + 1]);
}
if (!_playback.PushBuffer(output)) return;
FinishPreroll();
_pendingOffsetFrames += frames;
_submittedFrames += frames;
if (_pendingOffsetFrames == _pending.FrameCount)
{
_pending = null;
_pendingOffsetFrames = 0;
}
}
}
private void Start()
{
_player.Play();
_player.StreamPaused = true;
_playback = _player.GetStreamPlayback() as AudioStreamGeneratorPlayback
?? throw new InvalidOperationException("Godot did not create movie audio generator playback");
_started = true;
_prerolling = true;
}
private void FinishPreroll()
{
if (!_prerolling) return;
_prerolling = false;
_player.StreamPaused = false;
}
private static StringName RouteBus(MovieAudioRoute route) => route switch
{
MovieAudioRoute.Music => "Music",
MovieAudioRoute.SoundEffect => "SFX",
MovieAudioRoute.Voice => "Voice",
_ => "Movie",
};
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_player.Stop();
_player.QueueFree();
}
}

View 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;
}
}

View File

@@ -4,14 +4,14 @@ 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 StartedAtTimestamp, long WatchdogMs)
long MovieFlags, long StartedAtTimestamp, long WatchdogMs)
{
public static MovieRuntime Open(string name, int assetId, long resourceId, MoviePayload payload,
IMovieDecoderFactory factory)
IMovieDecoderFactory factory, long movieFlags = 0)
{
ArgumentNullException.ThrowIfNull(factory);
IMovieDecoder decoder = factory.Open(payload);
return new MovieRuntime(name, assetId, resourceId, decoder, Stopwatch.GetTimestamp(),
return new MovieRuntime(name, assetId, resourceId, decoder, movieFlags, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? Math.Clamp(stopTime + 2000, 5000, 300000)
: 30000);