Enable paced FFmpeg movie playback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
148
godot/FfmpegMovieDecoder.cs
Normal 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);
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,6 +10,7 @@ internal interface IMovieDecoder : IDisposable
|
||||
{
|
||||
long? StopTimeMs { get; }
|
||||
bool IsCompleted { get; }
|
||||
string? Failure { get; }
|
||||
bool TryTakeFrame(out RgbaImage frame);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user