Enable paced FFmpeg movie playback

This commit is contained in:
gamer147
2026-07-22 08:39:27 -04:00
parent 5f115e694f
commit 5b66cb2cd2
12 changed files with 365 additions and 22 deletions

View File

@@ -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

View File

@@ -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/<rid>/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

View File

@@ -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

View File

@@ -25,6 +25,7 @@
<Compile Include="..\..\godot\IMovieDecoder.cs" Link="IMovieDecoder.cs" />
<Compile Include="..\..\godot\MovieRuntime.cs" Link="MovieRuntime.cs" />
<Compile Include="..\..\godot\FfmpegMovieNative.cs" Link="FfmpegMovieNative.cs" />
<Compile Include="..\..\godot\FfmpegMovieDecoder.cs" Link="FfmpegMovieDecoder.cs" />
<Compile Include="..\..\godot\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
</ItemGroup>

View File

@@ -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<FfmpegVideoFrame> _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")

View File

@@ -35,6 +35,7 @@ internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB
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; }

148
godot/FfmpegMovieDecoder.cs Normal file
View File

@@ -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;
}
}
}
/// <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.
/// </summary>
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);
}

View File

@@ -15,7 +15,7 @@ internal readonly record struct FfmpegMovieInfo(
internal sealed record FfmpegVideoFrame(RgbaImage Image, long PresentationTimeMs);
/// <summary>Sequential, unpaced access to the project-owned FFmpeg C ABI for isolated probes and playback.</summary>
internal sealed class FfmpegMovieSession : IDisposable
internal sealed class FfmpegMovieSession : IFfmpegFrameSource
{
private FfmpegMovieHandle _handle;
public FfmpegMovieInfo Info { get; }

View File

@@ -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)

View File

@@ -7,4 +7,12 @@
<ItemGroup>
<ProjectReference Include="..\engine\Age.Engine\Age.Engine.csproj" />
</ItemGroup>
<ItemGroup Condition="Exists('..\build\native\win-x64\age_movie_ffmpeg.dll')">
<None Include="..\build\native\win-x64\*.dll"
Link="%(Filename)%(Extension)"
CopyToOutputDirectory="PreserveNewest" />
<None Include="..\build\native\win-x64\FFmpeg-LICENSE.txt"
Link="FFmpeg-LICENSE.txt"
CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>

View File

@@ -10,6 +10,7 @@ internal interface IMovieDecoder : IDisposable
{
long? StopTimeMs { get; }
bool IsCompleted { get; }
string? Failure { get; }
bool TryTakeFrame(out RgbaImage frame);
}

View File

@@ -53,10 +53,10 @@ 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();
// 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<long, MovieRuntime> _pendingMovies = new();
private IMovieDecoderFactory _movieDecoderFactory = new DirectShowMovieDecoderFactory();
private IMovieDecoderFactory _movieDecoderFactory = new FfmpegMovieDecoderFactory();
private readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _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);