Add isolated FFmpeg movie decoder shim

This commit is contained in:
gamer147
2026-07-22 00:17:27 -04:00
parent 633e3b3085
commit 5f115e694f
16 changed files with 1118 additions and 36 deletions

View File

@@ -100,6 +100,9 @@ S:\Game Hacking\Eushully\Himegari\ ← workspace root (three siblings)
├── engine/ DELIVERABLE — the .NET VM core (AgeEngine.sln: Age.Engine / Age.Cli / tests)
│ └── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store,
│ script provider, AGF/LZSS and Windows CUR decoders, and resource facade
├── native/ authored native runtime boundaries
│ └── age_movie_ffmpeg/ project-owned FFmpeg C ABI, immutable Windows dependency manifest,
│ and bootstrap/build scripts (outputs stay under disposable build/)
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine),
including the TITLE-only F4 debug scene launcher

View File

@@ -582,9 +582,55 @@ an infinite callback count: the total horizon starts at `base_time + 1000`, and
loop polls `0x23a` until surfaces 7..10 are inactive. MVB908's stop log occurs after that loop. As a bounded
safety correction, decoder failure now becomes an explicitly completed zero-duration effect, while started
movies have a stop-time-based completion watchdog so a missing EOF cannot hold `0x21c` forever. **NEXT:**
manually recheck the same exchange. If it still stalls, capture the VM/service coordinate after BTL cleanup
as a separate timed-presentation issue; otherwise proceed to the software MPEG backend for real 280x352
duration/frames. Preserve created destination dimensions and failed-movie identity through that backend change.
replace DirectShow through the bounded FFmpeg plan below. The user confirmed that combat can still stall, but
that symptom remains tabled until the replacement removes DirectShow as a variable. Preserve created destination
dimensions and failed-movie identity through the backend change.
### Portable FFmpeg movie backend selected; bounded implementation plan (2026-07-21)
FFmpeg is selected as the single movie backend for stock content and future profiles. The exact native ABI,
dependency pin, corpus codec inventory, timing contract, and packaging rules live in
`docs/platform-portability.md`; this section owns execution order and acceptance.
1. **Completed — land the seam without changing behavior.** Add `IMovieDecoder` plus an injected factory, type
`MovieRuntime` against the interface, and adapt DirectShow temporarily. Cover synchronous metadata, frame
handoff, EOF, failure-as-completed, replacement, cancellation, and disposal with fakes. This commit must retain
current live behavior and gives the FFmpeg work a testable boundary.
2. **Completed — prove the native ABI in isolation on Windows x64.** Add the C shim and pinned shared FFmpeg dependency,
then open VFS-owned bytes without a temporary file. The initial gate decodes changing RGBA frames and positive
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
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
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
an additional release/acceptance gate. Spot comparisons against DirectShow-compatible assets establish timing
tolerance before DirectShow is removed.
5. **Validate the live sequences, then delete DirectShow.** Recheck LOGO/OP cancellation, SC0000 CHAPTER,
aligned and 280-wide battle effects, combat cleanup, and the previously observed post-effect stall. Once those
pass, delete the COM declarations/temp-file path and Windows platform annotation rather than retaining a
fallback. If combat still stalls with FFmpeg, capture the VM/service coordinate after BTL cleanup and treat it
as a separate battle-timeline defect.
6. **Complete distributable packaging.** Bundle pinned dynamically linked libraries and exact license/source
provenance per target, validate loader isolation from machine-installed codecs, then add Linux/macOS build and
smoke gates. Timestamped PCM delivery and Godot `AudioStreamGenerator` integration remain a later movie-audio
slice; the video replacement must merely avoid closing that path.
Steps 1 and 2 landed on 2026-07-21 without changing live playback: `Main` owns an
`IMovieDecoderFactory`, `MovieRuntime` is backend-neutral, and the default factory still constructs DirectShow.
The isolated Windows-x64 C ABI opens VFS bytes through seekable custom AVIO and decodes changing RGBA frames from
both formerly failing 280x352 effects (`MVB961` = 500 ms, `MVB238` = 866 ms), aligned `MVB908` (333 ms), and
800x600 `CHAPTER` (12016 ms). Truncated input is rejected with a bounded error and ten repeated open/decode/close
cycles pass. During the full-suite gate, direct swscale output into a managed array exposed native heap corruption;
conversion now targets an FFmpeg-owned aligned frame and copies only the exact visible RGBA rows across the ABI.
The complete 313-test suite, three additional 312-test concurrency-sensitive repeats, zero-warning Godot build,
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.
**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

View File

@@ -23,8 +23,8 @@ also softer Windows assumptions that should be tested or replaced before claimin
| Area | Current dependency | Runtime impact | Portability status / future action |
|---|---|---|---|
| AGE movie decode (`0x236` scene movies; `0x20f` modal LOGO/OP/ED) | `godot/DirectShowMovieDecoder.cs`: DirectShow COM objects plus `ole32.dll` `CoInitializeEx` / `CoUninitialize` | Movie playback cannot run outside Windows; the current Windows filter stack also rejects the installed MPEG effects whose display width is 8 mod 16 | Introduce a decoder interface and backend factory; use a software MPEG backend for portable coverage and as the Windows fallback for unsupported effect dimensions |
| Movie integration | `godot/Main.cs` directly constructs and stores `DirectShowMovieDecoder`; `Main` is marked `SupportedOSPlatform("windows")` | The frontend has no runtime fallback or OS-specific source selection | Type `MovieRuntime` against the decoder interface, select by platform/build, and move the Windows annotation to the DirectShow backend |
| 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 |
| 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 |
@@ -45,27 +45,57 @@ The existing connection is localized but one abstraction short of being replacea
VM op 0x236 (non-modal) / op 0x20f (modal)
-> IHost.PlayMovieToSurface / PlayModalMovieToSurface
-> VFS-owned MoviePayload bytes
-> DirectShowMovieDecoder
-> IMovieDecoderFactory
-> DirectShowMovieDecoder (current live selection)
-> FfmpegMovieSession -> age_movie C ABI (isolated gate only)
-> newest RGBA frame
-> retained movie surface
-> Godot compositor
```
Everything before and after `DirectShowMovieDecoder` is portable. The backend currently exposes the right
conceptual operations (`StopTimeMs`, `TryTakeFrame`, `IsCompleted`, and `Dispose`) but they are not formalized as an
interface. A future cleanup should:
Everything before and after the selected decoder is portable. The replacement decision is an in-process
FFmpeg backend behind a project-owned C ABI, not raw FFmpeg structs in Godot/C# and not a subprocess. FFmpeg
`n8.1.2-29-g703dcc25b9` is pinned by immutable release URL and SHA-256 in
`native/age_movie_ffmpeg/dependency-win64.json`; changing that pin requires rerunning the full installed-movie
gate. The shim dynamically links an LGPL build made without GPL or nonfree components and
uses only `libavformat`, `libavcodec`, `libavutil`, and `libswscale` for the video slice. `libswresample` and the
MPEG audio decoders may be packaged now, but PCM delivery remains a separate slice. Release artifacts must carry
the matching FFmpeg source/configuration and notices required by FFmpeg's
[license checklist](https://ffmpeg.org/legal.html).
1. Add an `IMovieDecoder` contract for synchronous stop-time metadata, frame delivery, completion, failure,
and disposal. Stop time must be available before `0x236` returns so an immediately following `0x23f`
remains deterministic.
2. Add an injected factory that accepts `MoviePayload` and selects an available backend.
3. Keep DirectShow in a Windows-specific source set or assembly, with its platform annotation local to it.
4. Implement a portable MPEG program-stream backend that produces the same top-down RGBA8 frames.
5. Preserve newest-frame-wins delivery, asynchronous decode, synchronous open failure, EOF notification,
and the retained-surface lifecycle already validated for opcode `0x236`; expose modal completion/cancel
separately for `0x20f` rather than blocking inside the decoder backend.
6. Treat synchronized movie audio as a separate extension of the contract rather than coupling it to the
compositor.
The boundary has two layers:
1. A small native `age_movie` ABI owns all FFmpeg objects and version-sensitive calls. It accepts a borrowed
byte span only for the duration of `open`, copies it into native-owned memory, creates a seekable custom
`AVIOContext`, probes the MPEG program stream, and returns immutable video metadata. Sequential decode returns
one top-down tightly packed RGBA8 frame plus its normalized presentation timestamp. Conversion occurs in an
FFmpeg-owned aligned frame; only exact visible row bytes are copied into the caller's tightly packed buffer.
Return statuses distinguish frame, EOF, invalid arguments, undersized output, and decoder failure; the open
call returns a bounded UTF-8 diagnostic and an opened handle retains its last decode error. Close accepts a
null handle, and managed `SafeHandle` ownership guarantees one close for each successfully opened handle. No
FFmpeg pointer crosses the ABI.
2. Managed `IMovieDecoder` owns the native handle and the paced worker. Synchronous construction provides
`StopTimeMs` before `0x236` returns. The worker uses a monotonic playback origin, decodes ahead by at most one
frame, publishes frames when their timestamps become due, and retains newest-frame-wins behavior if Godot is
late. EOF becomes completion only after the final frame's presentation interval/stop time, so surface cleanup
cannot erase the last frame immediately. Disposal interrupts waits and joins without depending on Godot's main
thread.
`StopTimeMs` performs a bounded packet scan over the seekable in-memory payload and takes the longest usable
FFmpeg format duration, video-stream duration, timestamp span, or constant-frame-rate packet-count duration. This
accounts for the final MPEG presentation interval that FFmpeg's stream duration can omit (`MVB908` is ten 30 fps
frames: 333 ms, not the raw stream field's 316 ms). The result is converted to integer milliseconds with the same
positive truncation used by native op `0x23f`, then the demuxer rewinds before playback. Streams with no usable
duration evidence fail initialization for the stock-content path rather than inventing timing. The existing host
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
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.
This replacement is now also required for Windows gameplay parity. Archive-backed probes on 2026-07-21
show that the current DirectShow graph accepts tested MPEG widths divisible by 16 (208, 288, 304, 400, and
@@ -73,10 +103,29 @@ show that the current DirectShow graph accepts tested MPEG widths divisible by 1
The dominant combat-effect family is 280x352 (125 installed `MVB` assets), so retaining DirectShow as the
only Windows decoder is insufficient even though SC0000's 800x600 `CHAPTER.AGF` works.
An OS-specific build is also viable: include DirectShow only in Windows exports and a different decoder in
other exports. The current plain `net8.0` project has no conditional backend selection. COM declarations may
compile on another OS, but `[SupportedOSPlatform]` is analyzer metadata rather than a runtime guard, and
loading `ole32.dll` or DirectShow CLSIDs will fail there.
An archive scan on the same date classified all 213 installed movie payloads as MPEG-1 program streams with
MPEG-1 video and no MPEG-2 sequence extensions. Of those, 184 are video-only and 29 contain MPEG audio. The
large LOGO/OP/ED family uses MPEG Audio Layer II, while 25 small 120x120 `MVS` movies use Layer I. That inventory
is why the narrower `pl_mpeg` library was rejected as the primary backend: it fits every installed video stream
but only decodes Layer II audio and explicitly ignores program-stream PTS in its high-level synchronization.
FFmpeg covers the complete installed codec set and leaves the mod/profile boundary open without selecting a
different decoder per effect.
Native deliverables are RID-specific and bundled with the Godot export; the runtime must not discover an
arbitrary system FFmpeg. The first implementation gate is Windows x64 because that is the current runnable
target, but the C ABI and loader paths must reserve Windows x64, Linux x64, macOS x64, and macOS arm64 from the
start. Builds use shared libraries, `$ORIGIN`/`@loader_path`-style local lookup on Unix targets, recorded source
hashes and configure arguments, and no committed original-game data. Packaging automation is part of completing
the backend, not a prerequisite for the first native decode spike.
The Windows-x64 spike is now complete. `bootstrap-win64.ps1` verifies the immutable archive SHA before
extraction, and `build-win64.ps1` builds the shim with MSVC and places the DLL, import artifacts, required LGPL
shared libraries, and license under disposable `build/native/win-x64`. The managed resolver accepts
`AGE_FFMPEG_NATIVE_DIR` for the isolated gate and otherwise reserves application-local and
`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.
## Cross-platform validation gates

View File

@@ -72,6 +72,20 @@ All opcode knowledge (ABI, semantics, provenance, `depends_on`) is hand-edited *
|---|---|---|---|
| `locate_page.py` | Resolve a run-relative ADV page number to its canonical wait script/offset, last show-text instruction, call stack, and nearby disassembly. Pure selection/window logic is tested by `test_locate_page.py`. | `py -3.11 -X utf8 tools/locate_page.py SC0000 14 [--map <jsonl>] [--context N]` | `build/page-map-<SCENE>.jsonl` + script corpus → stdout |
## 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.
| Tool | Purpose | Run | Reads → Writes |
|---|---|---|---|
| `native/age_movie_ffmpeg/bootstrap-win64.ps1` | Download, hash-check, extract, and version-check the pinned FFmpeg SDK. Returns the resolved SDK root. | `.\\native\\age_movie_ffmpeg\\bootstrap-win64.ps1 [-Destination <dir>]` | `dependency-win64.json`, network/archive cache → disposable `build/downloads/`, `build/ffmpeg-sdk/` |
| `native/age_movie_ffmpeg/build-win64.ps1` | Discover the MSVC x64 toolchain, build `age_movie_ffmpeg.dll`, and stage its exact shared-library/license dependencies. | `.\\native\\age_movie_ffmpeg\\build-win64.ps1 -SdkRoot <bootstrap-output> [-OutputDirectory <dir>]` | C ABI source + FFmpeg SDK → disposable `build/native/win-x64/` by default |
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.
## Engine (C#) — VM core, CLI, Godot frontend
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not

View File

@@ -22,6 +22,9 @@
<ItemGroup>
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
<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\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
</ItemGroup>

View File

@@ -8,6 +8,73 @@ using Xunit;
public class MovieOpcodeTests
{
private sealed class FakeMovieDecoder : IMovieDecoder
{
public long? StopTimeMs { get; init; }
public bool IsCompleted { get; set; }
public bool Disposed { get; private set; }
public RgbaImage? Frame { get; set; }
public bool TryTakeFrame(out RgbaImage frame)
{
if (Frame == null) { frame = default!; return false; }
frame = Frame;
Frame = null;
return true;
}
public void Dispose() => Disposed = true;
}
private sealed class FakeMovieDecoderFactory(FakeMovieDecoder decoder) : IMovieDecoderFactory
{
public MoviePayload? OpenedPayload { get; private set; }
public IMovieDecoder Open(MoviePayload movie)
{
OpenedPayload = movie;
return decoder;
}
}
[Fact]
public void MovieRuntimeUsesInjectedDecoderAndRetainsSynchronousMetadata()
{
var decoder = new FakeMovieDecoder
{
StopTimeMs = 1876,
Frame = new RgbaImage(1, 1, new byte[] { 1, 2, 3, 4 }),
};
var factory = new FakeMovieDecoderFactory(decoder);
var payload = new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba });
var runtime = MovieRuntime.Open("TEST.AGF", 7, payload, factory);
Assert.Same(payload, factory.OpenedPayload);
Assert.Same(decoder, runtime.Decoder);
Assert.Equal(1876, runtime.Decoder.StopTimeMs);
Assert.Equal(5000, runtime.WatchdogMs);
Assert.True(runtime.Decoder.TryTakeFrame(out var frame));
Assert.Equal(new byte[] { 1, 2, 3, 4 }, frame.Pixels);
runtime.Decoder.Dispose();
Assert.True(decoder.Disposed);
}
[Theory]
[InlineData(null, 30000)]
[InlineData(-1L, 30000L)]
[InlineData(10000L, 12000L)]
[InlineData(500000L, 300000L)]
public void MovieRuntimeComputesBoundedWatchdogFromDecoderMetadata(long? stopTimeMs, long expected)
{
var decoder = new FakeMovieDecoder { StopTimeMs = stopTimeMs };
var runtime = MovieRuntime.Open("TEST.AGF", 7,
new MoviePayload("TEST.AGF", new byte[] { 0, 0, 1, 0xba }),
new FakeMovieDecoderFactory(decoder));
Assert.Equal(expected, runtime.WatchdogMs);
runtime.Decoder.Dispose();
}
[Fact]
public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns()
{
@@ -252,4 +319,81 @@ public class MovieOpcodeTests
}
Assert.True(changed, "DirectShow should deliver changing MPEG frames, not one retained still");
}
[Theory]
[InlineData(0x2be3, "MVB961.AGF", 280, 352, 500)]
[InlineData(0x2b94, "MVB238.AGF", 280, 352, 866)]
[InlineData(0x2bc2, "MVB908.AGF", 400, 400, 333)]
[InlineData(0x33, "CHAPTER.AGF", 800, 600, 12016)]
public void FfmpegShimDecodesRepresentativeVfsMovie(int resourceId, string expectedName,
int expectedWidth, int expectedHeight,
long expectedStopTimeMs)
{
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)!);
using var movie = new FfmpegMovieSession(payload);
Assert.Equal(expectedName, payload.Name);
Assert.Equal(expectedWidth, movie.Info.Width);
Assert.Equal(expectedHeight, movie.Info.Height);
Assert.Equal(expectedStopTimeMs, movie.Info.StopTimeMs);
Assert.True(movie.TryDecodeNextVideoFrame(out var first));
Assert.Equal(expectedWidth * expectedHeight * 4, first.Image.Pixels.Length);
Assert.True(first.PresentationTimeMs >= 0);
bool changed = false;
long priorTimestamp = first.PresentationTimeMs;
for (int frameIndex = 0; frameIndex < 30 && movie.TryDecodeNextVideoFrame(out var later); frameIndex++)
{
Assert.True(later.PresentationTimeMs >= priorTimestamp);
priorTimestamp = later.PresentationTimeMs;
if (!first.Image.Pixels.AsSpan().SequenceEqual(later.Image.Pixels))
{
changed = true;
break;
}
}
Assert.True(changed, $"{expectedName} should deliver changing decoded frames");
}
[Fact]
public void FfmpegShimRejectsTruncatedMovieWithBoundedDiagnostic()
{
if (!OperatingSystem.IsWindows()) return;
ConfigureFfmpegNativeProbe();
var payload = new MoviePayload("TRUNCATED.AGF", new byte[] { 0, 0, 1, 0xba, 0x21, 0, 1, 0 });
var error = Assert.Throws<InvalidDataException>(() => new FfmpegMovieSession(payload));
Assert.Contains("TRUNCATED.AGF", error.Message);
Assert.Contains("FFmpeg", error.Message);
}
[Fact]
public void FfmpegShimSupportsRepeatedOpenAndClose()
{
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(0x2bc2)!);
for (int iteration = 0; iteration < 10; iteration++)
{
using var movie = new FfmpegMovieSession(payload);
Assert.True(movie.TryDecodeNextVideoFrame(out _));
}
}
private static void ConfigureFfmpegNativeProbe()
{
string nativeDirectory = Environment.GetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR")
?? Path.Combine(Paths.Build, "native", "win-x64");
Assert.True(File.Exists(Path.Combine(nativeDirectory, "age_movie_ffmpeg.dll")),
$"Build the FFmpeg shim first: native/age_movie_ffmpeg/build-win64.ps1 (expected {nativeDirectory})");
Environment.SetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR", nativeDirectory);
}
}

View File

@@ -18,7 +18,7 @@ internal interface ISampleGrabberCB
/// Decoded RGB32 samples are copied into process memory and consumed by Godot's retained compositor.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB
internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB
{
private readonly byte[] _payload;
private readonly Thread _thread;

162
godot/FfmpegMovieNative.cs Normal file
View File

@@ -0,0 +1,162 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Age.Engine.Sys4;
using Microsoft.Win32.SafeHandles;
internal readonly record struct FfmpegMovieInfo(
int Width,
int Height,
long StopTimeMs,
int FrameRateNumerator,
int FrameRateDenominator,
bool HasAudio);
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
{
private FfmpegMovieHandle _handle;
public FfmpegMovieInfo Info { get; }
public FfmpegMovieSession(MoviePayload movie)
{
ArgumentNullException.ThrowIfNull(movie);
if (FfmpegMovieNative.AbiVersion() != 1)
throw new InvalidOperationException("unsupported age_movie_ffmpeg ABI version");
byte[] error = new byte[1024];
int result = FfmpegMovieNative.Open(movie.Bytes, (nuint)movie.Bytes.Length,
out _handle, out var nativeInfo, error, (nuint)error.Length);
if (result != 0 || _handle.IsInvalid)
{
_handle?.Dispose();
throw new InvalidDataException(
$"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);
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");
}
}
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);
if (result == FfmpegMovieNative.EndOfFile)
{
frame = default!;
return false;
}
if (result != FfmpegMovieNative.Frame)
throw new InvalidDataException($"FFmpeg movie decode failed: {FfmpegMovieNative.LastError(_handle)}");
frame = new FfmpegVideoFrame(new RgbaImage(Info.Width, Info.Height, pixels), ptsMs);
return true;
}
public void Dispose() => _handle.Dispose();
}
internal sealed class FfmpegMovieHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private FfmpegMovieHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle()
{
FfmpegMovieNative.Close(handle);
return true;
}
}
internal static class FfmpegMovieNative
{
internal const int EndOfFile = 0;
internal const int Frame = 1;
private const string LibraryName = "age_movie_ffmpeg";
[StructLayout(LayoutKind.Sequential)]
internal struct NativeInfo
{
public int Width;
public int Height;
public long StopTimeMs;
public int FrameRateNumerator;
public int FrameRateDenominator;
public int HasAudio;
}
static FfmpegMovieNative()
{
NativeLibrary.SetDllImportResolver(typeof(FfmpegMovieNative).Assembly, ResolveLibrary);
}
private static IntPtr ResolveLibrary(string libraryName, System.Reflection.Assembly assembly,
DllImportSearchPath? searchPath)
{
if (!string.Equals(libraryName, LibraryName, StringComparison.Ordinal)) return IntPtr.Zero;
string fileName = OperatingSystem.IsWindows()
? "age_movie_ffmpeg.dll"
: OperatingSystem.IsMacOS() ? "libage_movie_ffmpeg.dylib" : "libage_movie_ffmpeg.so";
string? configured = Environment.GetEnvironmentVariable("AGE_FFMPEG_NATIVE_DIR");
string rid = OperatingSystem.IsWindows() ? "win-x64"
: OperatingSystem.IsMacOS() ? (RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64")
: "linux-x64";
string[] candidates =
{
configured == null ? "" : Path.Combine(configured, fileName),
Path.Combine(AppContext.BaseDirectory, fileName),
Path.Combine(AppContext.BaseDirectory, "runtimes", rid, "native", fileName),
};
foreach (string candidate in candidates)
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
return NativeLibrary.Load(candidate);
throw new DllNotFoundException(
$"{fileName} was not found; set AGE_FFMPEG_NATIVE_DIR or package runtimes/{rid}/native");
}
internal static string DecodeUtf8(byte[] buffer)
{
int length = Array.IndexOf(buffer, (byte)0);
if (length < 0) length = buffer.Length;
return System.Text.Encoding.UTF8.GetString(buffer, 0, length);
}
internal static string LastError(FfmpegMovieHandle handle)
{
IntPtr text = LastErrorNative(handle);
return text == IntPtr.Zero ? "unknown decoder error" : Marshal.PtrToStringUTF8(text) ?? "unknown decoder error";
}
internal static uint AbiVersion() => AbiVersionNative();
[DllImport(LibraryName, EntryPoint = "age_movie_abi_version", CallingConvention = CallingConvention.Cdecl)]
private static extern uint AbiVersionNative();
[DllImport(LibraryName, EntryPoint = "age_movie_open", CallingConvention = CallingConvention.Cdecl)]
internal static extern int Open(
[In] byte[] bytes,
nuint length,
out FfmpegMovieHandle movie,
out NativeInfo info,
[Out] byte[] errorBuffer,
nuint errorBufferSize);
[DllImport(LibraryName, EntryPoint = "age_movie_decode_video", CallingConvention = CallingConvention.Cdecl)]
internal static extern int DecodeVideo(
FfmpegMovieHandle movie,
[Out] byte[] rgba,
nuint rgbaSize,
out long presentationTimeMs);
[DllImport(LibraryName, EntryPoint = "age_movie_last_error", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr LastErrorNative(FfmpegMovieHandle movie);
[DllImport(LibraryName, EntryPoint = "age_movie_close", CallingConvention = CallingConvention.Cdecl)]
internal static extern void Close(IntPtr movie);
}

25
godot/IMovieDecoder.cs Normal file
View File

@@ -0,0 +1,25 @@
using System;
using System.Runtime.Versioning;
using Age.Engine.Sys4;
/// <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.
/// </summary>
internal interface IMovieDecoder : IDisposable
{
long? StopTimeMs { get; }
bool IsCompleted { get; }
bool TryTakeFrame(out RgbaImage frame);
}
internal interface IMovieDecoderFactory
{
IMovieDecoder Open(MoviePayload movie);
}
[SupportedOSPlatform("windows")]
internal sealed class DirectShowMovieDecoderFactory : IMovieDecoderFactory
{
public IMovieDecoder Open(MoviePayload movie) => new DirectShowMovieDecoder(movie);
}

View File

@@ -56,6 +56,7 @@ public partial class Main : Godot.Control
// 0x236 creates its graph 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 readonly System.Collections.Generic.HashSet<long> _movieFrameSeen = new();
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
private GodotTraceSink _trace = null!;
@@ -1183,7 +1184,7 @@ public partial class Main : Godot.Control
try
{
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
var runtime = new MovieRuntime(assetName, assetId, new DirectShowMovieDecoder(payload));
var runtime = MovieRuntime.Open(assetName, assetId, payload, _movieDecoderFactory);
stopTimeMs = runtime.Decoder.StopTimeMs;
while (!_pendingMovies.TryAdd(resourceId, runtime))
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
@@ -1242,16 +1243,6 @@ public partial class Main : Godot.Control
_movieCompletionNotified.Remove(resourceId);
}
private sealed record MovieRuntime(string Name, int AssetId, DirectShowMovieDecoder Decoder,
long StartedAtTimestamp, long WatchdogMs)
{
public MovieRuntime(string name, int assetId, DirectShowMovieDecoder decoder)
: this(name, assetId, decoder, Stopwatch.GetTimestamp(),
decoder.StopTimeMs is >= 0 and var stopTime
? System.Math.Clamp(stopTime + 2000, 5000, 300000)
: 30000) { }
}
public void AppendLine(string text) => _text.Text += text + "\n";
public void PageBreak()
{

19
godot/MovieRuntime.cs Normal file
View File

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

View File

@@ -0,0 +1,475 @@
#include "age_movie.h"
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavutil/avutil.h>
#include <libavutil/error.h>
#include <libavutil/imgutils.h>
#include <libavutil/mathematics.h>
#include <libswscale/swscale.h>
#define AGE_MOVIE_ABI_VERSION 1u
#define AGE_MOVIE_IO_BUFFER_SIZE 32768
#define AGE_MOVIE_ERROR_SIZE 512
struct age_movie {
uint8_t *payload;
size_t payload_size;
size_t payload_position;
AVIOContext *io;
AVFormatContext *format;
AVCodecContext *video_codec;
AVPacket *packet;
AVFrame *frame;
AVFrame *rgba_frame;
struct SwsContext *sws;
int video_stream_index;
AVRational video_time_base;
int64_t timestamp_origin;
int64_t first_frame_timestamp;
int input_eof;
int decoder_draining;
int decoder_eof;
int64_t decoded_frame_count;
int width;
int height;
char error[AGE_MOVIE_ERROR_SIZE];
};
static void copy_error(char *destination, size_t destination_size, const char *message) {
if (destination == NULL || destination_size == 0) {
return;
}
snprintf(destination, destination_size, "%s", message != NULL ? message : "unknown FFmpeg error");
}
static void set_error(age_movie *movie, const char *operation, int ffmpeg_error) {
char detail[AV_ERROR_MAX_STRING_SIZE] = {0};
if (av_strerror(ffmpeg_error, detail, sizeof(detail)) < 0) {
snprintf(detail, sizeof(detail), "FFmpeg error %d", ffmpeg_error);
}
snprintf(movie->error, sizeof(movie->error), "%s: %s", operation, detail);
}
static int read_packet(void *opaque, uint8_t *buffer, int buffer_size) {
age_movie *movie = (age_movie *)opaque;
if (movie == NULL || buffer == NULL || buffer_size <= 0) {
return AVERROR(EINVAL);
}
if (movie->payload_position >= movie->payload_size) {
return AVERROR_EOF;
}
size_t remaining = movie->payload_size - movie->payload_position;
size_t requested = (size_t)buffer_size;
size_t count = remaining < requested ? remaining : requested;
memcpy(buffer, movie->payload + movie->payload_position, count);
movie->payload_position += count;
return (int)count;
}
static int64_t seek_payload(void *opaque, int64_t offset, int whence) {
age_movie *movie = (age_movie *)opaque;
if (movie == NULL) {
return AVERROR(EINVAL);
}
if ((whence & AVSEEK_SIZE) != 0) {
return movie->payload_size <= INT64_MAX ? (int64_t)movie->payload_size : AVERROR(EOVERFLOW);
}
int base_whence = whence & ~AVSEEK_FORCE;
int64_t base;
switch (base_whence) {
case SEEK_SET: base = 0; break;
case SEEK_CUR:
if (movie->payload_position > INT64_MAX) return AVERROR(EOVERFLOW);
base = (int64_t)movie->payload_position;
break;
case SEEK_END:
if (movie->payload_size > INT64_MAX) return AVERROR(EOVERFLOW);
base = (int64_t)movie->payload_size;
break;
default: return AVERROR(EINVAL);
}
if (offset == INT64_MIN || (offset > 0 && base > INT64_MAX - offset) ||
(offset < 0 && base < -offset)) {
return AVERROR(EOVERFLOW);
}
int64_t target = base + offset;
if (target < 0 || (uint64_t)target > movie->payload_size) {
return AVERROR(EINVAL);
}
movie->payload_position = (size_t)target;
return target;
}
static int64_t rescale_ms_down(int64_t value, AVRational time_base) {
return av_rescale_q_rnd(value, time_base, (AVRational){1, 1000},
AV_ROUND_DOWN | AV_ROUND_PASS_MINMAX);
}
static int determine_duration(age_movie *movie, AVStream *stream, int64_t *out_duration_ms) {
int64_t best_duration_ms = 0;
if (stream->duration != AV_NOPTS_VALUE && stream->duration > 0) {
int64_t candidate = rescale_ms_down(stream->duration, stream->time_base);
if (candidate > best_duration_ms) best_duration_ms = candidate;
}
if (movie->format->duration != AV_NOPTS_VALUE && movie->format->duration > 0) {
int64_t candidate = av_rescale_q_rnd(movie->format->duration,
(AVRational){1, AV_TIME_BASE}, (AVRational){1, 1000},
AV_ROUND_DOWN | AV_ROUND_PASS_MINMAX);
if (candidate > best_duration_ms) best_duration_ms = candidate;
}
int64_t first = AV_NOPTS_VALUE;
int64_t end = AV_NOPTS_VALUE;
int64_t video_packet_count = 0;
AVPacket *probe = av_packet_alloc();
if (probe == NULL) return AVERROR(ENOMEM);
int result;
while ((result = av_read_frame(movie->format, probe)) >= 0) {
if (probe->stream_index == movie->video_stream_index) {
video_packet_count++;
int64_t timestamp = probe->pts != AV_NOPTS_VALUE ? probe->pts : probe->dts;
if (timestamp != AV_NOPTS_VALUE) {
if (first == AV_NOPTS_VALUE || timestamp < first) first = timestamp;
int64_t packet_end = timestamp;
if (probe->duration > 0 && timestamp <= INT64_MAX - probe->duration) {
packet_end += probe->duration;
}
if (end == AV_NOPTS_VALUE || packet_end > end) end = packet_end;
}
}
av_packet_unref(probe);
}
av_packet_free(&probe);
if (result != AVERROR_EOF) return result;
if (first != AV_NOPTS_VALUE && end != AV_NOPTS_VALUE && end > first) {
int64_t candidate = rescale_ms_down(end - first, stream->time_base);
if (candidate > best_duration_ms) best_duration_ms = candidate;
}
AVRational average_rate = stream->avg_frame_rate;
if (video_packet_count > 0 && average_rate.num > 0 && average_rate.den > 0) {
int64_t candidate = av_rescale_q_rnd(video_packet_count, av_inv_q(average_rate),
(AVRational){1, 1000}, AV_ROUND_DOWN | AV_ROUND_PASS_MINMAX);
if (candidate > best_duration_ms) best_duration_ms = candidate;
}
*out_duration_ms = best_duration_ms;
return best_duration_ms > 0 ? 0 : AVERROR_INVALIDDATA;
}
static int rewind_for_decode(age_movie *movie, AVStream *stream) {
int64_t target = stream->start_time != AV_NOPTS_VALUE ? stream->start_time : 0;
int result = av_seek_frame(movie->format, movie->video_stream_index, target, AVSEEK_FLAG_BACKWARD);
if (result < 0) {
movie->payload_position = 0;
if (movie->io != NULL) {
avio_flush(movie->io);
movie->io->eof_reached = 0;
movie->io->error = 0;
}
result = avformat_seek_file(movie->format, movie->video_stream_index,
INT64_MIN, target, INT64_MAX, 0);
}
if (result >= 0) avcodec_flush_buffers(movie->video_codec);
return result;
}
static void destroy_movie(age_movie *movie) {
if (movie == NULL) return;
sws_freeContext(movie->sws);
av_frame_free(&movie->rgba_frame);
av_frame_free(&movie->frame);
av_packet_free(&movie->packet);
avcodec_free_context(&movie->video_codec);
avformat_close_input(&movie->format);
if (movie->io != NULL) {
av_freep(&movie->io->buffer);
avio_context_free(&movie->io);
}
av_free(movie->payload);
free(movie);
}
uint32_t AGE_MOVIE_CALL age_movie_abi_version(void) {
return AGE_MOVIE_ABI_VERSION;
}
int32_t AGE_MOVIE_CALL age_movie_open(const uint8_t *bytes, size_t length,
age_movie **out_movie, age_movie_info *out_info,
char *error_buffer, size_t error_buffer_size) {
if (out_movie != NULL) *out_movie = NULL;
if (out_info != NULL) memset(out_info, 0, sizeof(*out_info));
if (bytes == NULL || length == 0 || out_movie == NULL || out_info == NULL) {
copy_error(error_buffer, error_buffer_size, "invalid movie open arguments");
return AGE_MOVIE_INVALID_ARGUMENT;
}
age_movie *movie = (age_movie *)calloc(1, sizeof(*movie));
if (movie == NULL) {
copy_error(error_buffer, error_buffer_size, "movie allocation failed");
return AGE_MOVIE_ERROR;
}
movie->video_stream_index = -1;
movie->timestamp_origin = AV_NOPTS_VALUE;
movie->first_frame_timestamp = AV_NOPTS_VALUE;
if (length > SIZE_MAX - AV_INPUT_BUFFER_PADDING_SIZE) {
snprintf(movie->error, sizeof(movie->error), "movie payload is too large");
goto failure;
}
movie->payload = (uint8_t *)av_mallocz(length + AV_INPUT_BUFFER_PADDING_SIZE);
if (movie->payload == NULL) {
snprintf(movie->error, sizeof(movie->error), "movie payload allocation failed");
goto failure;
}
memcpy(movie->payload, bytes, length);
movie->payload_size = length;
uint8_t *io_buffer = (uint8_t *)av_malloc(AGE_MOVIE_IO_BUFFER_SIZE);
if (io_buffer == NULL) {
snprintf(movie->error, sizeof(movie->error), "movie IO buffer allocation failed");
goto failure;
}
movie->io = avio_alloc_context(io_buffer, AGE_MOVIE_IO_BUFFER_SIZE, 0,
movie, read_packet, NULL, seek_payload);
if (movie->io == NULL) {
av_free(io_buffer);
snprintf(movie->error, sizeof(movie->error), "movie AVIO context allocation failed");
goto failure;
}
movie->format = avformat_alloc_context();
if (movie->format == NULL) {
snprintf(movie->error, sizeof(movie->error), "movie format context allocation failed");
goto failure;
}
movie->format->pb = movie->io;
movie->format->flags |= AVFMT_FLAG_CUSTOM_IO;
int result = avformat_open_input(&movie->format, NULL, NULL, NULL);
if (result < 0) {
set_error(movie, "open MPEG program stream", result);
goto failure;
}
result = avformat_find_stream_info(movie->format, NULL);
if (result < 0) {
set_error(movie, "probe MPEG streams", result);
goto failure;
}
const AVCodec *video_decoder = NULL;
result = av_find_best_stream(movie->format, AVMEDIA_TYPE_VIDEO, -1, -1, &video_decoder, 0);
if (result < 0 || video_decoder == NULL) {
set_error(movie, "find MPEG video stream", result < 0 ? result : AVERROR_DECODER_NOT_FOUND);
goto failure;
}
movie->video_stream_index = result;
AVStream *stream = movie->format->streams[movie->video_stream_index];
movie->video_time_base = stream->time_base;
movie->timestamp_origin = stream->start_time;
movie->video_codec = avcodec_alloc_context3(video_decoder);
if (movie->video_codec == NULL) {
snprintf(movie->error, sizeof(movie->error), "video decoder allocation failed");
goto failure;
}
result = avcodec_parameters_to_context(movie->video_codec, stream->codecpar);
if (result < 0) {
set_error(movie, "copy video parameters", result);
goto failure;
}
result = avcodec_open2(movie->video_codec, video_decoder, NULL);
if (result < 0) {
set_error(movie, "open MPEG-1 video decoder", result);
goto failure;
}
movie->width = movie->video_codec->width;
movie->height = movie->video_codec->height;
if (movie->width <= 0 || movie->height <= 0) {
snprintf(movie->error, sizeof(movie->error), "invalid video dimensions %dx%d", movie->width, movie->height);
goto failure;
}
if ((size_t)movie->width > SIZE_MAX / 4u / (size_t)movie->height) {
snprintf(movie->error, sizeof(movie->error), "video dimensions overflow RGBA storage");
goto failure;
}
int64_t duration_ms = 0;
result = rewind_for_decode(movie, stream);
if (result < 0) {
set_error(movie, "rewind MPEG stream for duration scan", result);
goto failure;
}
result = determine_duration(movie, stream, &duration_ms);
if (result < 0) {
set_error(movie, "determine video duration", result);
goto failure;
}
result = rewind_for_decode(movie, stream);
if (result < 0) {
set_error(movie, "rewind MPEG stream", result);
goto failure;
}
movie->packet = av_packet_alloc();
movie->frame = av_frame_alloc();
movie->rgba_frame = av_frame_alloc();
if (movie->packet == NULL || movie->frame == NULL || movie->rgba_frame == NULL) {
snprintf(movie->error, sizeof(movie->error), "video packet/frame allocation failed");
goto failure;
}
movie->rgba_frame->format = AV_PIX_FMT_RGBA;
movie->rgba_frame->width = movie->width;
movie->rgba_frame->height = movie->height;
result = av_frame_get_buffer(movie->rgba_frame, 32);
if (result < 0) {
set_error(movie, "allocate aligned RGBA frame", result);
goto failure;
}
AVRational frame_rate = av_guess_frame_rate(movie->format, stream, NULL);
out_info->width = movie->width;
out_info->height = movie->height;
out_info->stop_time_ms = duration_ms;
out_info->frame_rate_num = frame_rate.num;
out_info->frame_rate_den = frame_rate.den;
for (unsigned int index = 0; index < movie->format->nb_streams; index++) {
if (movie->format->streams[index]->codecpar->codec_type == AVMEDIA_TYPE_AUDIO) {
out_info->has_audio = 1;
break;
}
}
*out_movie = movie;
copy_error(error_buffer, error_buffer_size, "");
return 0;
failure:
copy_error(error_buffer, error_buffer_size, movie->error);
destroy_movie(movie);
return AGE_MOVIE_ERROR;
}
int32_t AGE_MOVIE_CALL age_movie_decode_video(age_movie *movie,
uint8_t *rgba, size_t rgba_size, int64_t *out_pts_ms) {
if (movie == NULL || rgba == NULL || out_pts_ms == NULL) return AGE_MOVIE_INVALID_ARGUMENT;
size_t required = (size_t)movie->width * (size_t)movie->height * 4u;
if (rgba_size < required) return AGE_MOVIE_BUFFER_TOO_SMALL;
if (movie->decoder_eof) return AGE_MOVIE_EOF;
for (;;) {
int result = avcodec_receive_frame(movie->video_codec, movie->frame);
if (result == 0) {
movie->sws = sws_getCachedContext(movie->sws,
movie->frame->width, movie->frame->height, (enum AVPixelFormat)movie->frame->format,
movie->width, movie->height, AV_PIX_FMT_RGBA,
SWS_BILINEAR, NULL, NULL, NULL);
if (movie->sws == NULL) {
snprintf(movie->error, sizeof(movie->error), "create RGBA conversion context failed");
return AGE_MOVIE_ERROR;
}
result = av_frame_make_writable(movie->rgba_frame);
if (result < 0) {
set_error(movie, "make aligned RGBA frame writable", result);
return AGE_MOVIE_ERROR;
}
result = sws_scale(movie->sws,
(const uint8_t * const *)movie->frame->data, movie->frame->linesize,
0, movie->frame->height, movie->rgba_frame->data, movie->rgba_frame->linesize);
if (result != movie->height) {
snprintf(movie->error, sizeof(movie->error), "RGBA conversion returned %d of %d rows", result, movie->height);
return AGE_MOVIE_ERROR;
}
size_t row_size = (size_t)movie->width * 4u;
for (int row = 0; row < movie->height; row++) {
memcpy(rgba + (size_t)row * row_size,
movie->rgba_frame->data[0] + (ptrdiff_t)row * movie->rgba_frame->linesize[0],
row_size);
}
int64_t timestamp = movie->frame->best_effort_timestamp;
if (timestamp == AV_NOPTS_VALUE) {
timestamp = movie->frame->pts;
}
if (timestamp == AV_NOPTS_VALUE) {
AVRational rate = av_guess_frame_rate(movie->format,
movie->format->streams[movie->video_stream_index], movie->frame);
if (rate.num <= 0 || rate.den <= 0) {
snprintf(movie->error, sizeof(movie->error), "decoded frame has no timestamp or frame rate");
return AGE_MOVIE_ERROR;
}
*out_pts_ms = av_rescale_q_rnd(movie->decoded_frame_count, av_inv_q(rate),
(AVRational){1, 1000}, AV_ROUND_DOWN | AV_ROUND_PASS_MINMAX);
} else {
if (movie->timestamp_origin == AV_NOPTS_VALUE) {
if (movie->first_frame_timestamp == AV_NOPTS_VALUE) movie->first_frame_timestamp = timestamp;
movie->timestamp_origin = movie->first_frame_timestamp;
}
int64_t relative = timestamp >= movie->timestamp_origin ? timestamp - movie->timestamp_origin : 0;
*out_pts_ms = rescale_ms_down(relative, movie->video_time_base);
}
movie->decoded_frame_count++;
av_frame_unref(movie->frame);
return AGE_MOVIE_FRAME;
}
if (result == AVERROR_EOF) {
movie->decoder_eof = 1;
return AGE_MOVIE_EOF;
}
if (result != AVERROR(EAGAIN)) {
set_error(movie, "receive decoded video frame", result);
return AGE_MOVIE_ERROR;
}
if (movie->input_eof) {
if (!movie->decoder_draining) {
result = avcodec_send_packet(movie->video_codec, NULL);
if (result < 0 && result != AVERROR_EOF) {
set_error(movie, "flush MPEG-1 video decoder", result);
return AGE_MOVIE_ERROR;
}
movie->decoder_draining = 1;
continue;
}
snprintf(movie->error, sizeof(movie->error), "video decoder requested input after drain");
return AGE_MOVIE_ERROR;
}
for (;;) {
result = av_read_frame(movie->format, movie->packet);
if (result == AVERROR_EOF) {
movie->input_eof = 1;
break;
}
if (result < 0) {
set_error(movie, "read MPEG video packet", result);
return AGE_MOVIE_ERROR;
}
if (movie->packet->stream_index != movie->video_stream_index) {
av_packet_unref(movie->packet);
continue;
}
break;
}
if (movie->input_eof) continue;
result = avcodec_send_packet(movie->video_codec, movie->packet);
av_packet_unref(movie->packet);
if (result < 0) {
set_error(movie, "send MPEG video packet", result);
return AGE_MOVIE_ERROR;
}
}
}
const char *AGE_MOVIE_CALL age_movie_last_error(const age_movie *movie) {
return movie != NULL ? movie->error : "movie handle is null";
}
void AGE_MOVIE_CALL age_movie_close(age_movie *movie) {
destroy_movie(movie);
}

View File

@@ -0,0 +1,63 @@
#ifndef AGE_MOVIE_H
#define AGE_MOVIE_H
#include <stddef.h>
#include <stdint.h>
#if defined(_WIN32)
#define AGE_MOVIE_API __declspec(dllexport)
#define AGE_MOVIE_CALL __cdecl
#else
#define AGE_MOVIE_API __attribute__((visibility("default")))
#define AGE_MOVIE_CALL
#endif
#ifdef __cplusplus
extern "C" {
#endif
typedef struct age_movie age_movie;
typedef struct age_movie_info {
int32_t width;
int32_t height;
int64_t stop_time_ms;
int32_t frame_rate_num;
int32_t frame_rate_den;
int32_t has_audio;
} age_movie_info;
enum age_movie_result {
AGE_MOVIE_EOF = 0,
AGE_MOVIE_FRAME = 1,
AGE_MOVIE_ERROR = -1,
AGE_MOVIE_INVALID_ARGUMENT = -2,
AGE_MOVIE_BUFFER_TOO_SMALL = -3
};
AGE_MOVIE_API uint32_t AGE_MOVIE_CALL age_movie_abi_version(void);
/* Copies bytes before returning; the caller's buffer is borrowed only during this call. */
AGE_MOVIE_API int32_t AGE_MOVIE_CALL age_movie_open(
const uint8_t *bytes,
size_t length,
age_movie **out_movie,
age_movie_info *out_info,
char *error_buffer,
size_t error_buffer_size);
/* Writes one tightly packed top-down RGBA8 frame and its zero-based presentation timestamp. */
AGE_MOVIE_API int32_t AGE_MOVIE_CALL age_movie_decode_video(
age_movie *movie,
uint8_t *rgba,
size_t rgba_size,
int64_t *out_pts_ms);
AGE_MOVIE_API const char *AGE_MOVIE_CALL age_movie_last_error(const age_movie *movie);
AGE_MOVIE_API void AGE_MOVIE_CALL age_movie_close(age_movie *movie);
#ifdef __cplusplus
}
#endif
#endif

View File

@@ -0,0 +1,35 @@
param(
[string]$Destination
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
if ([string]::IsNullOrWhiteSpace($Destination)) {
$Destination = Join-Path $repoRoot 'build\ffmpeg-sdk'
}
$manifest = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'dependency-win64.json') -Raw | ConvertFrom-Json
$downloadDir = Join-Path $repoRoot 'build\downloads'
$archivePath = Join-Path $downloadDir $manifest.archive
$extractRoot = Join-Path $Destination ([IO.Path]::GetFileNameWithoutExtension($manifest.archive))
New-Item -ItemType Directory -Force -Path $downloadDir | Out-Null
if (-not (Test-Path -LiteralPath $archivePath)) {
Invoke-WebRequest -Uri $manifest.url -OutFile $archivePath
}
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archivePath).Hash.ToLowerInvariant()
if ($actualHash -ne $manifest.sha256) {
throw "FFmpeg archive SHA-256 mismatch: expected $($manifest.sha256), got $actualHash"
}
if (-not (Test-Path -LiteralPath $extractRoot)) {
Expand-Archive -LiteralPath $archivePath -DestinationPath $Destination
}
$sdkRoot = Get-ChildItem -LiteralPath $Destination -Directory |
Where-Object { Test-Path -LiteralPath (Join-Path $_.FullName 'include\libavformat\avformat.h') } |
Select-Object -First 1 -ExpandProperty FullName
if (-not $sdkRoot) { throw "FFmpeg SDK was not found under $Destination" }
$reported = & (Join-Path $sdkRoot 'bin\ffmpeg.exe') -version | Select-Object -First 1
$expectedVersion = $manifest.ffmpeg_version -replace '-20260721$', ''
if ($reported -notlike "*$expectedVersion*" ) {
throw "Unexpected FFmpeg build: $reported"
}
Write-Output $sdkRoot

View File

@@ -0,0 +1,43 @@
param(
[Parameter(Mandatory = $true)]
[string]$SdkRoot,
[string]$OutputDirectory
)
$ErrorActionPreference = 'Stop'
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$SdkRoot = (Resolve-Path $SdkRoot).Path
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$OutputDirectory = Join-Path $repoRoot 'build\native\win-x64'
}
$include = Join-Path $SdkRoot 'include'
$lib = Join-Path $SdkRoot 'lib'
$bin = Join-Path $SdkRoot 'bin'
foreach ($required in @(
(Join-Path $include 'libavformat\avformat.h'),
(Join-Path $lib 'avformat.lib'),
(Join-Path $bin 'avformat-62.dll')
)) {
if (-not (Test-Path -LiteralPath $required)) { throw "Missing FFmpeg SDK file: $required" }
}
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path -LiteralPath $vswhere)) { throw "Visual Studio Installer vswhere.exe was not found" }
$vsInstall = & $vswhere -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if (-not $vsInstall) { throw "A Visual C++ x64 build environment was not found" }
$vcvars = Join-Path $vsInstall 'VC\Auxiliary\Build\vcvars64.bat'
if (-not (Test-Path -LiteralPath $vcvars)) { throw "Visual C++ x64 environment not found: $vcvars" }
New-Item -ItemType Directory -Force -Path $OutputDirectory | Out-Null
$source = Join-Path $PSScriptRoot 'age_movie.c'
$object = Join-Path $OutputDirectory 'age_movie.obj'
$dll = Join-Path $OutputDirectory 'age_movie_ffmpeg.dll'
$importLibrary = Join-Path $OutputDirectory 'age_movie_ffmpeg.lib'
$compile = 'call "{0}" && cl.exe /nologo /std:c11 /utf-8 /O2 /MD /W4 /external:I"{1}" /external:W0 /LD /Fo"{2}" "{3}" /link /OUT:"{4}" /IMPLIB:"{5}" /LIBPATH:"{6}" avformat.lib avcodec.lib avutil.lib swscale.lib' -f
$vcvars, $include, $object, $source, $dll, $importLibrary, $lib
& cmd.exe /d /s /c $compile
if ($LASTEXITCODE -ne 0) { throw "age_movie_ffmpeg compilation failed with exit code $LASTEXITCODE" }
foreach ($runtime in @('avformat-62.dll', 'avcodec-62.dll', 'avutil-60.dll', 'swscale-9.dll', 'swresample-6.dll')) {
Copy-Item -LiteralPath (Join-Path $bin $runtime) -Destination $OutputDirectory -Force
}
Copy-Item -LiteralPath (Join-Path $SdkRoot 'LICENSE.txt') -Destination (Join-Path $OutputDirectory 'FFmpeg-LICENSE.txt') -Force
Write-Output $dll

View File

@@ -0,0 +1,10 @@
{
"provider": "BtbN/FFmpeg-Builds",
"release_tag": "autobuild-2026-07-21-13-38",
"archive": "ffmpeg-n8.1.2-29-g703dcc25b9-win64-lgpl-shared-8.1.zip",
"url": "https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-07-21-13-38/ffmpeg-n8.1.2-29-g703dcc25b9-win64-lgpl-shared-8.1.zip",
"sha256": "50764b52d38cb0baf8af938c0e4ca886d2c6753b520b2c319b532ed5c17e7cbf",
"ffmpeg_version": "n8.1.2-29-g703dcc25b9-20260721",
"ffmpeg_commit": "703dcc25b9",
"variant": "win64 LGPL shared 8.1"
}