Add isolated FFmpeg movie decoder shim
This commit is contained in:
@@ -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)
|
├── 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,
|
│ └── Age.Engine/Sys4/ runtime catalog parser, loose-first bounded ALF asset store,
|
||||||
│ script provider, AGF/LZSS and Windows CUR decoders, and resource facade
|
│ 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)
|
├── tools/frida/ runtime-capture + engine-dump scripts (see tools/frida/README.md)
|
||||||
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine),
|
└── godot/ DELIVERABLE — the Godot/C# ADV front-end (references Age.Engine),
|
||||||
including the TITLE-only F4 debug scene launcher
|
including the TITLE-only F4 debug scene launcher
|
||||||
|
|||||||
@@ -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
|
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
|
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:**
|
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
|
replace DirectShow through the bounded FFmpeg plan below. The user confirmed that combat can still stall, but
|
||||||
as a separate timed-presentation issue; otherwise proceed to the software MPEG backend for real 280x352
|
that symptom remains tabled until the replacement removes DirectShow as a variable. Preserve created destination
|
||||||
duration/frames. Preserve created destination dimensions and failed-movie identity through that backend change.
|
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
|
**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
|
as transparent. SYSTEM4 creates 800x600 surface 3 and fills it opaque white through `0x20b`; the metadata-only
|
||||||
|
|||||||
@@ -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 |
|
| 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 |
|
| 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 | `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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)
|
VM op 0x236 (non-modal) / op 0x20f (modal)
|
||||||
-> IHost.PlayMovieToSurface / PlayModalMovieToSurface
|
-> IHost.PlayMovieToSurface / PlayModalMovieToSurface
|
||||||
-> VFS-owned MoviePayload bytes
|
-> VFS-owned MoviePayload bytes
|
||||||
-> DirectShowMovieDecoder
|
-> IMovieDecoderFactory
|
||||||
|
-> DirectShowMovieDecoder (current live selection)
|
||||||
|
-> FfmpegMovieSession -> age_movie C ABI (isolated gate only)
|
||||||
-> newest RGBA frame
|
-> newest RGBA frame
|
||||||
-> retained movie surface
|
-> retained movie surface
|
||||||
-> Godot compositor
|
-> Godot compositor
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything before and after `DirectShowMovieDecoder` is portable. The backend currently exposes the right
|
Everything before and after the selected decoder is portable. The replacement decision is an in-process
|
||||||
conceptual operations (`StopTimeMs`, `TryTakeFrame`, `IsCompleted`, and `Dispose`) but they are not formalized as an
|
FFmpeg backend behind a project-owned C ABI, not raw FFmpeg structs in Godot/C# and not a subprocess. FFmpeg
|
||||||
interface. A future cleanup should:
|
`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,
|
The boundary has two layers:
|
||||||
and disposal. Stop time must be available before `0x236` returns so an immediately following `0x23f`
|
|
||||||
remains deterministic.
|
1. A small native `age_movie` ABI owns all FFmpeg objects and version-sensitive calls. It accepts a borrowed
|
||||||
2. Add an injected factory that accepts `MoviePayload` and selects an available backend.
|
byte span only for the duration of `open`, copies it into native-owned memory, creates a seekable custom
|
||||||
3. Keep DirectShow in a Windows-specific source set or assembly, with its platform annotation local to it.
|
`AVIOContext`, probes the MPEG program stream, and returns immutable video metadata. Sequential decode returns
|
||||||
4. Implement a portable MPEG program-stream backend that produces the same top-down RGBA8 frames.
|
one top-down tightly packed RGBA8 frame plus its normalized presentation timestamp. Conversion occurs in an
|
||||||
5. Preserve newest-frame-wins delivery, asynchronous decode, synchronous open failure, EOF notification,
|
FFmpeg-owned aligned frame; only exact visible row bytes are copied into the caller's tightly packed buffer.
|
||||||
and the retained-surface lifecycle already validated for opcode `0x236`; expose modal completion/cancel
|
Return statuses distinguish frame, EOF, invalid arguments, undersized output, and decoder failure; the open
|
||||||
separately for `0x20f` rather than blocking inside the decoder backend.
|
call returns a bounded UTF-8 diagnostic and an opened handle retains its last decode error. Close accepts a
|
||||||
6. Treat synchronized movie audio as a separate extension of the contract rather than coupling it to the
|
null handle, and managed `SafeHandle` ownership guarantees one close for each successfully opened handle. No
|
||||||
compositor.
|
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
|
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
|
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
|
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.
|
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
|
An archive scan on the same date classified all 213 installed movie payloads as MPEG-1 program streams with
|
||||||
other exports. The current plain `net8.0` project has no conditional backend selection. COM declarations may
|
MPEG-1 video and no MPEG-2 sequence extensions. Of those, 184 are video-only and 29 contain MPEG audio. The
|
||||||
compile on another OS, but `[SupportedOSPlatform]` is analyzer metadata rather than a runtime guard, and
|
large LOGO/OP/ED family uses MPEG Audio Layer II, while 25 small 120x120 `MVS` movies use Layer I. That inventory
|
||||||
loading `ole32.dll` or DirectShow CLSIDs will fail there.
|
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
|
## Cross-platform validation gates
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| `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
|
## Engine (C#) — VM core, CLI, Godot frontend
|
||||||
|
|
||||||
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not
|
The `engine/` .NET solution (`AgeEngine.sln`) is the runtime VM; `godot/` is the ADV frontend. Not
|
||||||
|
|||||||
@@ -22,6 +22,9 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\Age.Engine\Age.Engine.csproj" />
|
<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" />
|
<Compile Include="..\..\godot\DirectShowMovieDecoder.cs" Link="DirectShowMovieDecoder.cs" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,73 @@ using Xunit;
|
|||||||
|
|
||||||
public class MovieOpcodeTests
|
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]
|
[Fact]
|
||||||
public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns()
|
public void InitialRootFlagStartsSetAndClearsWhenExitScriptRuns()
|
||||||
{
|
{
|
||||||
@@ -252,4 +319,81 @@ public class MovieOpcodeTests
|
|||||||
}
|
}
|
||||||
Assert.True(changed, "DirectShow should deliver changing MPEG frames, not one retained still");
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ internal interface ISampleGrabberCB
|
|||||||
/// Decoded RGB32 samples are copied into process memory and consumed by Godot's retained compositor.
|
/// Decoded RGB32 samples are copied into process memory and consumed by Godot's retained compositor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[SupportedOSPlatform("windows")]
|
[SupportedOSPlatform("windows")]
|
||||||
internal sealed class DirectShowMovieDecoder : IDisposable, ISampleGrabberCB
|
internal sealed class DirectShowMovieDecoder : IMovieDecoder, ISampleGrabberCB
|
||||||
{
|
{
|
||||||
private readonly byte[] _payload;
|
private readonly byte[] _payload;
|
||||||
private readonly Thread _thread;
|
private readonly Thread _thread;
|
||||||
|
|||||||
162
godot/FfmpegMovieNative.cs
Normal file
162
godot/FfmpegMovieNative.cs
Normal 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
25
godot/IMovieDecoder.cs
Normal 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);
|
||||||
|
}
|
||||||
@@ -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.
|
// 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.
|
// Presentation ownership transfers here; _Process adopts staged decoders before sampling frames.
|
||||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, MovieRuntime> _pendingMovies = new();
|
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> _movieFrameSeen = new();
|
||||||
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
|
private readonly System.Collections.Generic.HashSet<long> _movieCompletionNotified = new();
|
||||||
private GodotTraceSink _trace = null!;
|
private GodotTraceSink _trace = null!;
|
||||||
@@ -1183,7 +1184,7 @@ public partial class Main : Godot.Control
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var payload = new Age.Engine.Sys4.MoviePayload(assetName, mpegBytes);
|
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;
|
stopTimeMs = runtime.Decoder.StopTimeMs;
|
||||||
while (!_pendingMovies.TryAdd(resourceId, runtime))
|
while (!_pendingMovies.TryAdd(resourceId, runtime))
|
||||||
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
|
if (_pendingMovies.TryRemove(resourceId, out var prior)) prior.Decoder.Dispose();
|
||||||
@@ -1242,16 +1243,6 @@ public partial class Main : Godot.Control
|
|||||||
_movieCompletionNotified.Remove(resourceId);
|
_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 AppendLine(string text) => _text.Text += text + "\n";
|
||||||
public void PageBreak()
|
public void PageBreak()
|
||||||
{
|
{
|
||||||
|
|||||||
19
godot/MovieRuntime.cs
Normal file
19
godot/MovieRuntime.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
475
native/age_movie_ffmpeg/age_movie.c
Normal file
475
native/age_movie_ffmpeg/age_movie.c
Normal 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);
|
||||||
|
}
|
||||||
63
native/age_movie_ffmpeg/age_movie.h
Normal file
63
native/age_movie_ffmpeg/age_movie.h
Normal 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
|
||||||
35
native/age_movie_ffmpeg/bootstrap-win64.ps1
Normal file
35
native/age_movie_ffmpeg/bootstrap-win64.ps1
Normal 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
|
||||||
43
native/age_movie_ffmpeg/build-win64.ps1
Normal file
43
native/age_movie_ffmpeg/build-win64.ps1
Normal 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
|
||||||
10
native/age_movie_ffmpeg/dependency-win64.json
Normal file
10
native/age_movie_ffmpeg/dependency-win64.json
Normal 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"
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user