From 816673cf6a2b3908a3d9753db6f5e55aacce650e Mon Sep 17 00:00:00 2001 From: gamer147 Date: Wed, 22 Jul 2026 08:39:27 -0400 Subject: [PATCH] Enable paced FFmpeg movie playback --- docs/phase-b-framework.md | 19 ++- docs/platform-portability.md | 30 ++-- docs/tools-reference.md | 8 +- .../Age.Engine.Tests/Age.Engine.Tests.csproj | 1 + engine/Age.Engine.Tests/MovieOpcodeTests.cs | 157 ++++++++++++++++++ godot/DirectShowMovieDecoder.cs | 1 + godot/FfmpegMovieDecoder.cs | 148 +++++++++++++++++ godot/FfmpegMovieNative.cs | 2 +- godot/GodotAdvHost.cs | 6 +- godot/Himegari.csproj | 8 + godot/IMovieDecoder.cs | 1 + godot/Main.cs | 6 +- 12 files changed, 365 insertions(+), 22 deletions(-) create mode 100644 godot/FfmpegMovieDecoder.cs diff --git a/docs/phase-b-framework.md b/docs/phase-b-framework.md index 50122ed..87e6295 100644 --- a/docs/phase-b-framework.md +++ b/docs/phase-b-framework.md @@ -601,11 +601,11 @@ dependency pin, corpus codec inventory, timing contract, and packaging rules liv stop times from one formerly failing 280x352 effect (`MVB961` or `MVB238`), aligned `MVB908`, and 800x600 `CHAPTER`. It must also reject a truncated payload with a bounded diagnostic and survive repeated open/close. Do not switch the live runtime at this step. -3. **Next — implement managed pacing and switch the factory.** Decode on a cancellable worker using FFmpeg timestamps +3. **Completed — implement managed pacing and switch the factory.** Decode on a cancellable worker using FFmpeg timestamps and a monotonic clock, publish only due frames, delay EOF completion through the final presentation interval, and retain the existing watchdog. Make FFmpeg the default with no dimension/effect dispatch and exercise both non-modal `0x236` and modal/cancellable `0x20f` paths. -4. **Run the installed-corpus gate.** Every one of the 213 MPEG payloads must open, report its expected display +4. **Next — run the installed-corpus gate.** Every one of the 213 MPEG payloads must open, report its expected display dimensions and a positive stop time, produce a correctly sized RGBA frame, maintain nondecreasing timestamps, reach EOF, and dispose within a bound. Normal tests use small project-authored 280-wide and aligned MPEG fixtures so decoder behavior is never disabled when the original install is absent; the full local sweep is @@ -632,6 +632,21 @@ The complete 313-test suite, three additional 312-test concurrency-sensitive rep and threaded selftest pass. Step 3 is the next bounded slice; do not infer that combat is fixed until the paced backend is selected live and revalidated. +Step 3 landed on 2026-07-22. `FfmpegMovieDecoder` owns the native session on a cancellable background thread, +decodes at most one frame ahead, waits against a monotonic `Stopwatch` clock for each normalized PTS, preserves +newest-frame-wins handoff, and reports EOF only after the larger of declared stop time or the final frame interval. +Disposal interrupts a far-future frame wait and joins the worker; asynchronous decode failure records a diagnostic +and completes the decoder so AGE cannot remain blocked. `Main` now selects `FfmpegMovieDecoderFactory`, while the +Godot build stages the shim and its five local shared-library dependencies beside `Himegari.dll`. DirectShow stays +in-tree but is no longer selected; delete it only after the corpus and live acceptance gates. Deterministic fake- +clock tests cover due-frame publication, final completion, cancellation, and failure. Real paced probes cover +formerly failing 280x352 `MVB961` and aligned `MVB908`. A natural headless SYSTEM4 smoke played and released +`LOGO.AGF` at its reported 7288 ms, then opened 106919 ms `OP.AGF` and published its first frame before bounded +shutdown. The user then confirmed the opening movies play correctly in a normal windowed run. The complete suite +is 318 tests, the Godot build is warning-free, and threaded selftest passes. **NEXT:** run the 213-payload +installed-corpus gate, then validate SC0000 CHAPTER and the original battle exchange before removing DirectShow +or judging the tabled combat stall. + **Mutable-surface fill/blend regression corrected.** The first visual recheck exposed BUNKI's menu interior as transparent. SYSTEM4 creates 800x600 surface 3 and fills it opaque white through `0x20b`; the metadata-only host fill left the new pixel buffer transparent. Implementing the fill alone made the panel solid gray and diff --git a/docs/platform-portability.md b/docs/platform-portability.md index a55a516..7f1e06d 100644 --- a/docs/platform-portability.md +++ b/docs/platform-portability.md @@ -16,16 +16,18 @@ The VM and content pipeline are already mostly platform-neutral: - Movie payloads arrive from `IAssetStore` as owned bytes and decoded frames enter the compositor as the platform-neutral `RgbaImage` type. -The current runtime's only direct native Windows API use is the movie decoder described below. There are -also softer Windows assumptions that should be tested or replaced before claiming portable exports. +The selected movie path now uses the project-owned FFmpeg C ABI rather than a Windows multimedia API, but only a +Windows-x64 native bundle is built and staged today. The retired-live DirectShow implementation remains in-tree +until the corpus and manual gates pass. There are also softer Windows assumptions that should be tested or +replaced before claiming portable exports. ## Dependency inventory | Area | Current dependency | Runtime impact | Portability status / future action | |---|---|---|---| -| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | Live playback still uses `godot/DirectShowMovieDecoder.cs`; the isolated `native/age_movie_ffmpeg` C ABI and `FfmpegMovieSession` probe path now decode the representative VFS movies | Live movies remain Windows-only and the selected live backend still rejects installed MPEG effects whose display width is 8 mod 16 | Add timestamp pacing to the proven FFmpeg path, select it as the sole live factory, run the corpus/live gates, then delete DirectShow | -| Movie integration | `MovieRuntime` now owns `IMovieDecoder` from an injected `IMovieDecoderFactory`; `Main` deliberately selects `DirectShowMovieDecoderFactory` and remains marked `SupportedOSPlatform("windows")` | Backend ownership is portable, but the selected live implementation is not yet | Implement the FFmpeg `IMovieDecoder`, switch the factory, and remove the Windows annotation when DirectShow is deleted | -| Movie audio | DirectShow connects only the video pin to the sample grabber/null renderer | The MPEG audio stream is intentionally silent on every platform | Design a PCM/audio-clock contract or let a future backend own synchronized A/V; separate feature slice | +| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `FfmpegMovieDecoder` is the selected live factory over the project-owned `native/age_movie_ffmpeg` ABI; `DirectShowMovieDecoder` remains unselected pending deletion | Windows-x64 live playback now covers formerly rejected 280-wide effects, but other native targets and the full 213-payload gate remain | Run the corpus and windowed live gates, then delete DirectShow and add target-specific native builds | +| Movie integration | `MovieRuntime` owns `IMovieDecoder` from an injected factory; the FFmpeg worker paces PTS against a monotonic clock and supports cancellation/failure completion | Backend ownership is portable, while `Main` remains annotated Windows because only the win-x64 bundle is available | Add Linux/macOS builds and remove the Windows annotation after DirectShow is deleted | +| Movie audio | FFmpeg detects the audio stream but the current ABI returns video frames only | MPEG movie audio remains intentionally silent | Extend the ABI with timestamped PCM and select an audio/presentation clock; separate feature slice | | ADV font discovery | `godot/Main.cs` probes `C:/Windows/Fonts` for Japanese fonts | Harmless fallback today, but appearance depends on host fonts | Bundle/configure a redistributable font or add platform-specific discovery | | Filesystem semantics | Several filename and containment comparisons use `OrdinalIgnoreCase`; installed assets are conventionally uppercase | Needs validation on case-sensitive filesystems; may hide casing or containment mistakes | Add Linux/macOS tests with mixed-case synthetic roots and use filesystem-appropriate containment rules | | Install/repository discovery | `engine/Age.Engine/Sys4/Paths.cs` finds `age-reimpl` above `AppContext.BaseDirectory` and assumes the current workspace sibling layout | Suitable for development, not packaged exports on any OS | Replace runtime discovery with a user-selected game root/profile; retain repository paths only for developer tools/tests | @@ -39,15 +41,16 @@ Godot abstractions. ## Movie backend replacement seam -The existing connection is localized but one abstraction short of being replaceable without edits: +The live connection is now backend-neutral: ``` VM op 0x236 (non-modal) / op 0x20f (modal) -> IHost.PlayMovieToSurface / PlayModalMovieToSurface -> VFS-owned MoviePayload bytes -> IMovieDecoderFactory - -> DirectShowMovieDecoder (current live selection) - -> FfmpegMovieSession -> age_movie C ABI (isolated gate only) + -> FfmpegMovieDecoder (current live selection) + -> FfmpegMovieSession -> age_movie C ABI + -> DirectShowMovieDecoder (unselected; retained only through acceptance) -> newest RGBA frame -> retained movie surface -> Godot compositor @@ -91,8 +94,9 @@ safety rule still converts any backend initialization failure into an explicitly completed zero-duration movie identity, and the presentation watchdog remains a last-resort guard for an initialized backend that never reaches EOF. -The factory is injection for tests and future decoder replacement, not runtime codec roulette. Once the FFmpeg -corpus gate passes it becomes the only shipped movie backend on every platform and DirectShow is deleted. Modal +The factory is injection for tests and future decoder replacement, not runtime codec roulette. FFmpeg is now the +selected Windows-x64 live backend; once its corpus and live gates pass, DirectShow is deleted rather than shipped +as a fallback. Modal completion/cancel remains owned above the decoder by the existing `0x20f` host path. A future audio implementation will consume timestamped PCM and may become the presentation clock; it must not change the VM-facing stop-time, surface, or cancellation contracts. @@ -125,7 +129,11 @@ shared libraries, and license under disposable `build/native/win-x64`. The manag `runtimes//native` lookup. Representative VFS results are `MVB961` 280x352/500 ms, `MVB238` 280x352/866 ms, `MVB908` 400x400/333 ms, and `CHAPTER` 800x600/12016 ms, all with changing frames and nondecreasing timestamps. Malformed input and repeated teardown are covered. This is not yet distributable -packaging and does not change the current DirectShow live selection. +packaging. `FfmpegMovieDecoder` now adds cancellable timestamp pacing and is the live selection. The Godot build +copies the shim, five required shared libraries, and FFmpeg license beside `Himegari.dll`; a natural SYSTEM4 +smoke completed 7288 ms `LOGO.AGF`, opened 106919 ms `OP.AGF`, and published frames from both without loading +DirectShow; the user subsequently confirmed both opening movies work in normal windowed playback. Full +export/source-offer packaging and non-Windows builds remain outstanding. ## Cross-platform validation gates diff --git a/docs/tools-reference.md b/docs/tools-reference.md index 948f491..edf3006 100644 --- a/docs/tools-reference.md +++ b/docs/tools-reference.md @@ -74,8 +74,8 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited * ## Native FFmpeg movie shim (Windows x64) -These PowerShell tools build the isolated native movie gate; they do not switch live Godot playback away from -DirectShow. The dependency manifest pins an immutable LGPL shared FFmpeg archive and SHA-256. +These PowerShell tools build the selected Windows-x64 live movie backend. The dependency manifest pins an +immutable LGPL shared FFmpeg archive and SHA-256. | Tool | Purpose | Run | Reads → Writes | |---|---|---|---| @@ -84,7 +84,9 @@ DirectShow. The dependency manifest pins an immutable LGPL shared FFmpeg archive The managed isolated probes load from `AGE_FFMPEG_NATIVE_DIR` when set, then application-local and `runtimes/win-x64/native` locations. Build the shim before running the `FfmpegShim*` tests; no original-game -movie is copied into the repository or native output. +movie is copied into the repository or native output. When `build/native/win-x64/age_movie_ffmpeg.dll` exists, +`dotnet build godot/Himegari.csproj` also stages the shim, its five DLL dependencies, and `FFmpeg-LICENSE.txt` +beside `Himegari.dll` for development playback. ## Engine (C#) — VM core, CLI, Godot frontend diff --git a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj index afc9d5c..1ac2eab 100644 --- a/engine/Age.Engine.Tests/Age.Engine.Tests.csproj +++ b/engine/Age.Engine.Tests/Age.Engine.Tests.csproj @@ -25,6 +25,7 @@ + diff --git a/engine/Age.Engine.Tests/MovieOpcodeTests.cs b/engine/Age.Engine.Tests/MovieOpcodeTests.cs index 1d4a9df..09558d0 100644 --- a/engine/Age.Engine.Tests/MovieOpcodeTests.cs +++ b/engine/Age.Engine.Tests/MovieOpcodeTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Diagnostics; using Age.Engine.Diagnostics; using Age.Engine.Hosting; using Age.Engine.Model; @@ -12,6 +13,7 @@ public class MovieOpcodeTests { public long? StopTimeMs { get; init; } public bool IsCompleted { get; set; } + public string? Failure { get; set; } public bool Disposed { get; private set; } public RgbaImage? Frame { get; set; } @@ -36,6 +38,57 @@ public class MovieOpcodeTests } } + private sealed class FakeFfmpegFrameSource(FfmpegMovieInfo info, + params FfmpegVideoFrame[] frames) : IFfmpegFrameSource + { + private readonly Queue _frames = new(frames); + private int _decodeCalls; + public FfmpegMovieInfo Info { get; } = info; + public int FailOnDecodeCall { get; init; } = -1; + public bool Disposed { get; private set; } + + public bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame) + { + if (_decodeCalls++ == FailOnDecodeCall) + throw new InvalidDataException("synthetic decode failure"); + return _frames.TryDequeue(out frame!); + } + + public void Dispose() => Disposed = true; + } + + private sealed class ManualMoviePacingClock : IMoviePacingClock, IDisposable + { + private readonly AutoResetEvent _advanced = new(false); + private long _now; + private long _waitingFor = -1; + + public bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation) + { + Interlocked.Exchange(ref _waitingFor, elapsedMilliseconds); + while (Interlocked.Read(ref _now) < elapsedMilliseconds) + { + int signalled = WaitHandle.WaitAny(new[] { cancellation, _advanced }); + if (signalled == 0) return false; + } + return true; + } + + public void AdvanceTo(long elapsedMilliseconds) + { + Interlocked.Exchange(ref _now, elapsedMilliseconds); + _advanced.Set(); + } + + public bool WaitForDeadline(long elapsedMilliseconds) => + SpinWait.SpinUntil(() => Interlocked.Read(ref _waitingFor) == elapsedMilliseconds, 1000); + + public void Dispose() => _advanced.Dispose(); + } + + private static FfmpegVideoFrame SyntheticMovieFrame(byte value, long timestamp) => + new(new RgbaImage(1, 1, new[] { value, value, value, (byte)255 }), timestamp); + [Fact] public void MovieRuntimeUsesInjectedDecoderAndRetainsSynchronousMetadata() { @@ -75,6 +128,78 @@ public class MovieOpcodeTests runtime.Decoder.Dispose(); } + [Fact] + public void FfmpegDecoderPublishesOnlyDueFramesAndCompletesAfterFinalInterval() + { + var source = new FakeFfmpegFrameSource( + new FfmpegMovieInfo(1, 1, 100, 20, 1, false), + SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 50)); + using var clock = new ManualMoviePacingClock(); + using var decoder = new FfmpegMovieDecoder(source, clock); + + RgbaImage? first = null; + Assert.True(SpinWait.SpinUntil(() => + { + if (!decoder.TryTakeFrame(out var frame)) return false; + first = frame; + return true; + }, 1000)); + Assert.Equal((byte)1, first!.Pixels[0]); + Assert.True(clock.WaitForDeadline(50)); + Assert.False(decoder.TryTakeFrame(out _)); + + clock.AdvanceTo(49); + Assert.False(decoder.TryTakeFrame(out _)); + clock.AdvanceTo(50); + RgbaImage? second = null; + Assert.True(SpinWait.SpinUntil(() => + { + if (!decoder.TryTakeFrame(out var frame)) return false; + second = frame; + return true; + }, 1000)); + Assert.Equal((byte)2, second!.Pixels[0]); + + Assert.True(clock.WaitForDeadline(100)); + clock.AdvanceTo(99); + Assert.False(decoder.IsCompleted); + clock.AdvanceTo(100); + Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000)); + Assert.Null(decoder.Failure); + } + + [Fact] + public void FfmpegDecoderDisposalInterruptsFutureFrameWait() + { + var source = new FakeFfmpegFrameSource( + new FfmpegMovieInfo(1, 1, 60040, 25, 1, false), + SyntheticMovieFrame(1, 0), SyntheticMovieFrame(2, 60000)); + using var clock = new ManualMoviePacingClock(); + var decoder = new FfmpegMovieDecoder(source, clock); + Assert.True(clock.WaitForDeadline(60000)); + + var elapsed = Stopwatch.StartNew(); + decoder.Dispose(); + + Assert.True(elapsed.Elapsed < TimeSpan.FromSeconds(1)); + Assert.True(source.Disposed); + } + + [Fact] + public void FfmpegDecoderFailureCompletesInsteadOfStrandingMovieWait() + { + var source = new FakeFfmpegFrameSource( + new FfmpegMovieInfo(1, 1, 100, 20, 1, false), SyntheticMovieFrame(1, 0)) + { + FailOnDecodeCall = 1, + }; + using var clock = new ManualMoviePacingClock(); + using var decoder = new FfmpegMovieDecoder(source, clock); + + Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 1000)); + Assert.Equal("synthetic decode failure", decoder.Failure); + } + [Fact] public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns() { @@ -388,6 +513,38 @@ public class MovieOpcodeTests } } + [Theory] + [InlineData(0x2be3, 280, 500, 450)] + [InlineData(0x2bc2, 400, 333, 300)] + public void FfmpegPacedDecoderKeepsRealMovieAliveThroughItsStopTime( + int resourceId, int expectedWidth, long expectedStopTimeMs, long minimumElapsedMs) + { + if (!OperatingSystem.IsWindows()) return; + ConfigureFfmpegNativeProbe(); + var catalog = Sys4AssetCatalog.Load(Paths.Sys4Ini); + var resources = new ResourceMap(catalog, new Sys4AssetStore(catalog, Paths.GameDir)); + var payload = resources.ReadMovie(resources.ResolveMovie(resourceId)!); + var elapsed = Stopwatch.StartNew(); + using var decoder = new FfmpegMovieDecoder(payload); + + RgbaImage? first = null; + Assert.True(SpinWait.SpinUntil(() => + { + if (!decoder.TryTakeFrame(out var frame)) return false; + first = frame; + return true; + }, 2000)); + Assert.Equal(expectedWidth, first!.Width); + Assert.Equal(expectedStopTimeMs, decoder.StopTimeMs); + Assert.False(decoder.IsCompleted); + Assert.True(SpinWait.SpinUntil(() => decoder.IsCompleted, 2000)); + Assert.True(elapsed.ElapsedMilliseconds >= minimumElapsedMs, + $"{expectedStopTimeMs} ms movie completed after only {elapsed.ElapsedMilliseconds} ms"); + Assert.Null(decoder.Failure); + Assert.True(decoder.TryTakeFrame(out var final)); + Assert.Equal(expectedWidth, final.Width); + } + private static void ConfigureFfmpegNativeProbe() { string nativeDirectory = Environment.GetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR") diff --git a/godot/DirectShowMovieDecoder.cs b/godot/DirectShowMovieDecoder.cs index 0d97aeb..4142aca 100644 --- a/godot/DirectShowMovieDecoder.cs +++ b/godot/DirectShowMovieDecoder.cs @@ -35,6 +35,7 @@ internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB private IMediaControl? _control; public bool IsCompleted => _completed; + public string? Failure => _error; /// 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. public long? StopTimeMs { get; private set; } diff --git a/godot/FfmpegMovieDecoder.cs b/godot/FfmpegMovieDecoder.cs new file mode 100644 index 0000000..132df52 --- /dev/null +++ b/godot/FfmpegMovieDecoder.cs @@ -0,0 +1,148 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Threading; +using Age.Engine.Sys4; + +internal interface IFfmpegFrameSource : IDisposable +{ + FfmpegMovieInfo Info { get; } + bool TryDecodeNextVideoFrame(out FfmpegVideoFrame frame); +} + +internal interface IMoviePacingClock +{ + bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation); +} + +internal sealed class StopwatchMoviePacingClock : IMoviePacingClock +{ + private readonly long _startedAt = Stopwatch.GetTimestamp(); + + public bool WaitUntil(long elapsedMilliseconds, WaitHandle cancellation) + { + while (true) + { + double remaining = elapsedMilliseconds + - Stopwatch.GetElapsedTime(_startedAt).TotalMilliseconds; + if (remaining <= 0) return true; + int waitMilliseconds = (int)Math.Clamp(Math.Ceiling(remaining), 1, 1000); + if (cancellation.WaitOne(waitMilliseconds)) return false; + } + } +} + +/// +/// 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. +/// +internal sealed class FfmpegMovieDecoder : IMovieDecoder +{ + private readonly IFfmpegFrameSource _source; + private readonly IMoviePacingClock _clock; + private readonly ManualResetEvent _cancel = new(false); + private readonly Thread _thread; + private readonly object _frameLock = new(); + private RgbaImage? _latestFrame; + private volatile bool _completed; + private string? _failure; + private int _disposed; + + public long? StopTimeMs => _source.Info.StopTimeMs; + public bool IsCompleted => _completed; + public string? Failure => Volatile.Read(ref _failure); + + public FfmpegMovieDecoder(MoviePayload movie) + : this(new FfmpegMovieSession(movie), new StopwatchMoviePacingClock()) { } + + internal FfmpegMovieDecoder(IFfmpegFrameSource source, IMoviePacingClock clock) + { + _source = source ?? throw new ArgumentNullException(nameof(source)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _thread = new Thread(DecodeThread) + { + IsBackground = true, + Name = "AGE FFmpeg movie", + }; + try { _thread.Start(); } + catch + { + _source.Dispose(); + _cancel.Dispose(); + throw; + } + } + + public bool TryTakeFrame(out RgbaImage frame) + { + lock (_frameLock) + { + if (_latestFrame == null) + { + frame = default!; + return false; + } + frame = _latestFrame; + _latestFrame = null; + return true; + } + } + + private void DecodeThread() + { + try + { + long lastTimestamp = -1; + long decodedFrames = 0; + while (!_cancel.WaitOne(0)) + { + if (!_source.TryDecodeNextVideoFrame(out var frame)) + { + if (decodedFrames == 0) + 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; + return; + } + if (frame.PresentationTimeMs < 0 || frame.PresentationTimeMs < lastTimestamp) + throw new InvalidDataException( + $"FFmpeg returned non-monotonic video timestamp {frame.PresentationTimeMs} after {lastTimestamp}"); + if (!_clock.WaitUntil(frame.PresentationTimeMs, _cancel)) return; + lock (_frameLock) _latestFrame = frame.Image; + lastTimestamp = frame.PresentationTimeMs; + decodedFrames++; + } + } + catch (Exception error) + { + Volatile.Write(ref _failure, error.Message); + _completed = true; // decode failure must never strand an AGE movie wait + } + finally + { + _source.Dispose(); + } + } + + private static long FrameIntervalMilliseconds(FfmpegMovieInfo info) + { + if (info.FrameRateNumerator <= 0 || info.FrameRateDenominator <= 0) return 0; + long scaledDenominator = checked((long)info.FrameRateDenominator * 1000); + return Math.Max(1, scaledDenominator / info.FrameRateNumerator); + } + + public void Dispose() + { + if (Interlocked.Exchange(ref _disposed, 1) != 0) return; + _cancel.Set(); + if (_thread.IsAlive && Thread.CurrentThread != _thread) + _thread.Join(); + _cancel.Dispose(); + } +} + +internal sealed class FfmpegMovieDecoderFactory : IMovieDecoderFactory +{ + public IMovieDecoder Open(MoviePayload movie) => new FfmpegMovieDecoder(movie); +} diff --git a/godot/FfmpegMovieNative.cs b/godot/FfmpegMovieNative.cs index a8010f4..076b1c3 100644 --- a/godot/FfmpegMovieNative.cs +++ b/godot/FfmpegMovieNative.cs @@ -15,7 +15,7 @@ internal readonly record struct FfmpegMovieInfo( internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs); /// Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback. -internal sealed class FfmpegMovieSession : IDisposable +internal sealed class FfmpegMovieSession : IFfmpegFrameSource { private FfmpegMovieHandle _handle; public FfmpegMovieInfo Info { get; } diff --git a/godot/GodotAdvHost.cs b/godot/GodotAdvHost.cs index eefd352..9754b25 100644 --- a/godot/GodotAdvHost.cs +++ b/godot/GodotAdvHost.cs @@ -873,8 +873,8 @@ public sealed class GodotAdvHost : IHost { if (_movieFrames.TryGetValue(resId, out var movie)) return (movie.Image, movie.Name, movie.AssetId, true); - // Movie payloads use the same .AGF extension as still images. While DirectShow is opening - // the graph (or before its first sample arrives), keep the already-created surface blank + // Movie payloads use the same .AGF extension as still images. While the decoder is opening + // (or before its first frame arrives), keep the already-created surface blank // instead of falling through to AgfDecoder and misclassifying the MPEG program stream. if (_movieBySurface.Values.Contains(resId)) return null; } @@ -956,7 +956,7 @@ public sealed class GodotAdvHost : IHost long syncMask, bool modal, out long? stopTimeMs) { stopTimeMs = null; - // Publish the movie identity before the potentially long VFS read and DirectShow graph setup. + // Publish the movie identity before the potentially long VFS read and synchronous decoder setup. // The compositor can therefore distinguish a legitimate blank pre-roll surface from a still AGF. ReleaseSurface(surfaceSlot); lock (_imageLock) diff --git a/godot/Himegari.csproj b/godot/Himegari.csproj index a70c332..ed5a53e 100644 --- a/godot/Himegari.csproj +++ b/godot/Himegari.csproj @@ -7,4 +7,12 @@ + + + + diff --git a/godot/IMovieDecoder.cs b/godot/IMovieDecoder.cs index da0a0fb..f630db0 100644 --- a/godot/IMovieDecoder.cs +++ b/godot/IMovieDecoder.cs @@ -10,6 +10,7 @@ internal interface IMovieDecoder : IDisposable { long? StopTimeMs { get; } bool IsCompleted { get; } + string? Failure { get; } bool TryTakeFrame(out RgbaImage frame); } diff --git a/godot/Main.cs b/godot/Main.cs index a75ddea..935643c 100644 --- a/godot/Main.cs +++ b/godot/Main.cs @@ -53,10 +53,10 @@ public partial class Main : Godot.Control private IReadOnlyList _debugSceneEntries = System.Array.Empty(); private readonly Age.Engine.Hosting.FrameClock _clock = new(); private readonly System.Collections.Generic.Dictionary _movies = new(); - // 0x236 creates its graph synchronously on the VM thread so 0x23f can query timing immediately. + // 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 _pendingMovies = new(); - private IMovieDecoderFactory _movieDecoderFactory = new DirectShowMovieDecoderFactory(); + private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory(); private readonly System.Collections.Generic.HashSet _movieFrameSeen = new(); private readonly System.Collections.Generic.HashSet _movieCompletionNotified = new(); private GodotTraceSink _trace = null!; @@ -1224,6 +1224,8 @@ public partial class Main : Godot.Control >= movie.WatchdogMs; if ((movie.Decoder.IsCompleted || watchdogExpired) && _movieCompletionNotified.Add(resourceId)) { + if (movie.Decoder.Failure is { } failure) + GD.Print($"movie decode failed {movie.Name}: {failure}"); if (watchdogExpired && !movie.Decoder.IsCompleted) GD.Print($"movie completion watchdog {movie.Name}: forcing completion after {movie.WatchdogMs} ms"); _host.NotifyMovieCompleted(resourceId);