Add synchronized MPEG movie audio
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
182
godot/MovieAudioOutput.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
50
godot/MovieAudioTimeline.cs
Normal file
50
godot/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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user