Files
punktfunk/design/native-amf-encoder.md
T
enricobuehler 2c1bb4de93
apple / swift (push) Successful in 1m13s
android / android (push) Failing after 2m16s
ci / rust (push) Failing after 40s
ci / web (push) Successful in 53s
apple / screenshots (push) Failing after 3m21s
ci / docs-site (push) Successful in 1m0s
arch / build-publish (push) Successful in 5m17s
windows-host / package (push) Successful in 7m30s
deb / build-publish (push) Successful in 3m19s
decky / build-publish (push) Successful in 13s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
ci / bench (push) Successful in 4m51s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m47s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m33s
docker / deploy-docs (push) Successful in 20s
docs(design): native AMF encoder handoff — drop ffmpeg for AMF, keep for QSV
Design + decision record for replacing the libavcodec *_amf path with a
direct AMF SDK encoder (encode/windows/amf.rs, the AMD analogue of the direct
NVENC path). Motivation is measured, not speculative: the libavcodec wrapper
structurally holds ~2 frames (36 ms p50 at 720p60 on VCN, un-tunable), driver
wedges surface as forever-EAGAIN instead of typed AMF_RESULTs, and
intra-refresh / in-band HDR SEI are inexpressible through it. Covers the C
vtable FFI strategy (amfrt64.dll runtime-load, FFmpeg amfenc.c and OBS
texture-amf.cpp as references), bounded-poll retrieval, the property table,
watchdog/reset interplay, exact dispatch seams, a lab-iGPU validation plan
with today's zero-copy baselines, and three phases ending with the ffmpeg-AMF
arm deleted (FFmpeg stays QSV-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:36:55 +02:00

15 KiB
Raw Blame History

Native AMF encoder — handoff design

Status: PROPOSED (handoff, 2026-07-06). Design + decision record for replacing the libavcodec *_amf path with a direct AMF SDK encoder on Windows AMD (encode/windows/amf.rs, the AMD analogue of the direct-SDK encode/windows/nvenc.rs). Written for an implementer without prior context; every in-repo seam is named. Companion context: the encode-stall watchdog + Encoder::reset() (punktfunk1.rs / encode.rs, shipped 2026-07-06) and the AMF/QSV backend module docs in encode/windows/ffmpeg_win.rs.

1. Why (measured, not speculative)

Three independent reasons, in order of weight:

  1. The libavcodec AMF wrapper's structural ~2-frame output hold. hevc_amf/av1_amf need frame N+2 submitted before they release frame N's AU. Measured on the Ryzen 7000 iGPU (VCN, 720p60): encode→retrieve 36 ms p50, dead-stable, invariant across pipeline depth 1/2, every usage preset, and any spin budget (a 150 ms poll spin provably never produced the owed AU — it pegged at exactly 150 ms). See the poll doc comment in ffmpeg_win.rs. The direct-SDK NVENC path retrieves in ~12 ms. At 60 Hz this is ~33 ms of pure pipeline latency no FFmpeg-side knob can remove; at 120 Hz it is two whole frame budgets.
  2. Silent driver wedges surface as forever-EAGAIN, not errors. The field failure (AMD/Intel streams freezing after ~35 min) was invisible because the wrapper's only "not ready" signal is EAGAIN, indistinguishable from a healthy pipeline warming up. The 2026-07-06 watchdog converts that into a bounded rebuild + IDR, but it is a safety net with a ~2 s detection floor. The AMF runtime itself returns typed AMF_RESULT codes (AMF_INPUT_FULL, device-lost, etc.) — a native path sees the wedge on the frame it happens.
  3. Feature gaps libavcodec cannot express. No intra-refresh wave (every FEC-unrecoverable loss is answered with a full IDR — the 2040× frame-size spike the Linux NVENC intra-refresh mode exists to avoid), no in-band HDR mastering SEI (EncoderCaps::supports_hdr_metadata is NVENC-only today), coarse per-frame control.

2. The decision: drop FFmpeg for AMF, keep it for QSV

Native AMF replaces the libavcodec AMF path (phased, §7). FFmpeg stays for QSV.

  • QSV via libavcodec with async_depth=1 + low_power VDEnc is already near the hardware latency floor; a direct libvpl port would buy little for its cost. Revisit only if Intel field data shows a QSV-specific gap (separate doc if so).
  • Because QSV stays on FFmpeg, the FFmpeg DLLs keep shipping and the amf-qsv build feature keeps existing. Dropping FFmpeg entirely is therefore not on the table here — "drop" means: the AMF dispatch stops going through it once the native path is validated.
  • During bring-up the ffmpeg-AMF path remains as an automatic open-failure fallback and an explicit escape hatch (§7), then its AMF dispatch is deleted in Phase 3. Two permanently maintained AMF paths would double the driver-matrix burden, and the one we'd keep "for safety" is precisely the one with the wedge/latency pathology.

3. Architecture

New module crates/punktfunk-host/src/encode/windows/amf.rs implementing crate::encode::Encoder, compiled unconditionally on Windows (no new build-time dependency and no new cargo feature): the AMF runtime is loaded at runtime from the driver-installed amfrt64.dll, exactly as nvenc.rs loads nvEncodeAPI64.dll (load_api). A box without an AMD driver simply fails the open and the dispatch falls through. In-tree FFI decl module (amf_sys submodule or #[repr(C)] blocks in-file, mirroring the small interface subset we use) — model it on how ffmpeg_win.rs mirrors AVD3D11VADeviceContext rather than pulling a binding crate (none is maintained).

3.1 FFI strategy (the load-bearing detail)

The AMF public headers (GPUOpen AMF/amf/public/include) define C-compatible vtable structs for every interface (AMFFactoryVtbl, AMFContextVtbl, AMFComponentVtbl, AMFSurfaceVtbl, AMFDataVtbl, AMFBufferVtbl, AMFVariantStruct, …) — this is not a guess: FFmpeg's amfenc.c is plain C and drives AMF exclusively through those vtables, so the C ABI is the stable, supported surface. Mirror only what we call:

  • Entry points: GetProcAddress("AMFQueryVersion") (gate: runtime ≥ the pinned AMF_FULL_VERSION we mirror headers from) and GetProcAddress("AMFInit")AMFFactory*.
  • factory->CreateContextcontext->InitDX11(capturer_device, AMF_DX11_1)the capturer's own ID3D11Device, same-device requirement as every other backend (the capture textures are not shared-handle; see the ensure_inner_d3d11 rebind logic in ffmpeg_win.rs for the device-change lifecycle to replicate).
  • factory->CreateComponent(context, name) with AMFVideoEncoderVCE_AVC / AMFVideoEncoder_HEVC / AMFVideoEncoder_AV1encoder->Init(AMF_SURFACE_NV12|P010, w, h).
  • Per-frame: context->CreateSurfaceFromDX11Native(texture, &surface, observer) → per-surface properties (pts via SetPts, forced-IDR picture type) → encoder->SubmitInput(surface); retrieve via encoder->QueryOutput(&data) (AMF_REPEAT = not ready), AMFBuffer::GetNative/GetSizeEncodedFrame.
  • Every mirrored struct/call carries a // SAFETY: proof — the whole encode module tree is under #![deny(clippy::undocumented_unsafe_blocks)] (unsafe-proof program).

Reference implementations to crib from (read both before writing FFI): FFmpeg amfenc.c (the C vtbl usage, property plumbing, result-code handling) and OBS plugins/obs-ffmpeg/texture-amf.cpp (D3D11 texture submission + low-latency streaming config, C++ but the call sequence is what matters). Verify every property name against the pinned SDK headers — names below are from those references and must not be trusted blind.

3.2 Input path (zero-copy by construction)

Own a small D3D11 texture ring (NV12 or P010, D3D11_BIND_RENDER_TARGET | SHADER_RESOURCE, size = pipeline_depth + 2), CopySubresourceRegion the captured texture into the next slot (GPU-local, same pattern as ZeroCopyInner::submit), wrap the slot with CreateSurfaceFromDX11Native, submit. The copy decouples the encoder from the capturer's rotating IDD ring; do NOT wrap the capturer's texture directly. This makes PUNKTFUNK_ZEROCOPY irrelevant for native AMF — there is no readback path to fall back to. Handle the capturer's video-processor format fallback (Bgra/Rgb10a2 instead of NV12/P010 — see pool_mismatch in ffmpeg_win.rs) by returning an open/submit error in Phase 1 so dispatch falls back to the ffmpeg path; an AMFVideoConverter front-end is a Phase 2 option if that fallback ever fires in the field. FramePayload::Cpu (DDA without video processor): same treatment — ffmpeg fallback in Phase 1.

3.3 Retrieval model

Bounded-blocking poll, the vaapi.rs::poll model: after SubmitInput, spin QueryOutput with ~250 µs sleeps up to a budget of min(3/4 frame interval, 12 ms); on expiry return Ok(None) (the session loop keeps the frame in flight and the watchdog arbitrates wedges). VCN encode at streaming settings is ~15 ms, so the AU ships the same tick — this is where the ~2-frame hold dies. Expected observable — measure encode_us (submit→AU, in FrameMsg/the web-console stats), not wait_us: on the ffmpeg path the hold hides in encode_us (~2 frame periods) because its non-blocking poll returns EAGAIN in ~2 µs; on the native bounded poll the ASIC wait becomes visible as a few ms of wait_us while encode_us collapses to ~1 frame period or less.

3.4 Encoder configuration (initial property set)

Mirror the intent of the ffmpeg opts block in open_win_encoder (ffmpeg_win.rs:216-247). AVC names given; HEVC/AV1 have _HEVC_/_AV1_ twins — check headers:

Intent AMF property (verify!)
usage preset (keep PUNKTFUNK_AMF_USAGE mapping) AMF_VIDEO_ENCODER_USAGE = ULTRA_LOW_LATENCY (default)
CBR, target==peak RATE_CONTROL_METHOD=CBR, TARGET_BITRATE, PEAK_BITRATE
1-frame VBV (keep PUNKTFUNK_VBV_FRAMES) VBV_BUFFER_SIZE
HRD + no filler ENFORCE_HRD=true, FILLER_DATA_ENABLE=false
latency-first quality QUALITY_PRESET=SPEED
no B-frames (AVC) B_PIC_PATTERN=0
infinite GOP IDR_PERIOD=0 (HEVC: GOP_SIZE/NUM_GOPS_PER_IDR — check)
low-latency submission LOWLATENCY_MODE=true (newer SDKs)
in-band VPS/SPS/PPS on IDR (wire contract: EncodedFrame doc) HEVC HEADER_INSERTION_MODE=IDR_ALIGNED; AVC HEADER_INSERTION_SPACING — check
SDR/HDR VUI FULL_RANGE_COLOR=false + color primaries/transfer/matrix props (BT.709 vs BT.2020-PQ, mirroring open_win_encoder)
10-bit COLOR_BIT_DEPTH=10 + P010 surfaces
per-frame forced IDR on the input surface: AMF_VIDEO_ENCODER_FORCE_PICTURE_TYPE=IDR
intra-refresh wave (Phase 2) AVC INTRA_REFRESH_NUM_MBS_PER_SLOT; HEVC CTB twin — check
HDR mastering SEI (Phase 2) HEVC INPUT_HDR_METADATA (AMFHDRMetadata buffer)

SetProperty failures on optional properties (LOWLATENCY_MODE, intra-refresh) must be log-and-continue, not fatal — availability varies by VCN generation/driver.

3.5 Error + stall semantics (interplay with the 2026-07-06 watchdog)

  • SubmitInputAMF_INPUT_FULL: return Err from submit — the session loop's submit-failure path runs the bounded in-place reset. Any other non-OK result: Err.
  • QueryOutputAMF_REPEAT: keep spinning within the poll budget, then Ok(None). AMF_EOF: Ok(None) after flush. Anything else: Err (the loop's poll-error path resets).
  • Implement Encoder::reset() natively: encoder->Drain/Flush, Terminate(), re-Init on the same context (fall back to full context teardown if re-Init fails). Cheaper and more targeted than the ffmpeg path's drop-and-lazily-reopen.
  • caps(): supports_rfi: false (AMF has no NVENC-style reference invalidation — intra-refresh is the substitute), intra_refresh: true once Phase 2 lands (this flag is what makes the session glue rate-limit client keyframe requests — see the IDR_WINDOW logic in punktfunk1.rs), supports_hdr_metadata: true once the SEI lands, chroma_444: false permanently (VCN hardware does not encode 4:4:4; probe_can_encode_444 stays false — this is not an FFmpeg limitation).

3.6 Encoder trait contract (do not break)

From encode.rs + the punktfunk1 loop: AUs must come out FIFO in submit order (inflight.pop_front() pairs with poll order); data is Annex-B with in-band headers on IDRs (both a playable ES and self-contained wire AUs); poll returning Ok(None) is legal and watchdog-arbitrated; submit must never block indefinitely; after flush(), poll drains remaining AUs then returns Ok(None); single encode thread owns the encoder (manual unsafe impl Send with the same proof shape as FfmpegWinEncoder).

4. Integration seams (exact)

  • encode.rs::open_video_backend, WindowsBackend::Amf arm: try amf::AmfEncoder::open first; on Err, tracing::warn! + fall back to ffmpeg_win (when the amf-qsv feature is built) — the same graceful-degrade shape as zero-copy→system today. Escape hatch: PUNKTFUNK_AMF_FFMPEG=1 skips the native path (field triage). Phase 3 deletes both the fallback arm and the hatch.
  • resolved_backend_label / crate::gpu session record: new label "amf" stays (the mgmt API shows the same name; add "amf-ffmpeg" only if the fallback fires, so field logs distinguish the paths).
  • probe_can_encode (GameStream codec advertisement) and windows_codec_support: replace the ffmpeg open-probe with a native factory probe (CreateComponent per codec on the selected adapter) once Phase 2 lands; cache shape stays.
  • can_encode_444: unchanged (false).
  • The encode-stall watchdog and Encoder::reset (punktfunk1.rs): unchanged — it remains the backstop for in-FFI hangs the native path can't self-detect.

5. Validation plan (this box has an AMD iGPU — use it)

Baseline first, on the ffmpeg path (already deployed 2026-07-06 with the watchdog): a long session on the iGPU with PUNKTFUNK_PERF=1, record wait_us_p50/p99, encode_us, client-measured latency, and whether the watchdog ever fires. Then per phase:

  1. Open/probe smoke per codec (AVC, HEVC, HEVC-10) on the iGPU.
  2. A/B the encode latency: expect encode_us p50 ~2 frame periods → ≤ 1 frame period (see §3.3 for why wait_us is the wrong metric on the ffmpeg side); client capture→glass p50 drops by ~30 ms at 60 Hz / ~17 ms at 120 Hz. Zero-copy baseline for the input side already measured 2026-07-06 on the lab iGPU (1080p120 HDR P010): submit_us p50 2.72.9 ms (system readback) → 0.26 ms (zero-copy D3D11), p99 6.6 ms → 0.5 ms.
  3. Behavior: IDR on connect; mode switch mid-stream; HDR session (PQ VUI + 0xCE convergence); client keyframe-request recovery; encoder reset() under an injected failure; ≥30 min soak for the freeze class (watchdog log line encode stall detected must NOT appear).
  4. Driver matrix beyond the lab box is field data: VCN1 (Raven) through VCN4/5 differ in preset support — the optional-property tolerance in §3.4 is what absorbs this.

6. Risks / open questions

  • Vtable mirroring correctness is the concentrated risk: pin one AMF header version in a comment, mirror minimally, and unit-test AMFQueryVersion/AMFInit + a headless CreateComponent probe (skips cleanly on non-AMD boxes, like the NVENC live-gated tests).
  • Per-frame CreateSurfaceFromDX11Native allocation churn — if it shows up in profiles, AMF supports pre-created surface pools; start simple.
  • AV1 is RDNA3+; probe, never assume (same rule as everywhere in this codebase).
  • Hybrid boxes: context must init on the selected adapter's device (the capture device) — inherited for free by taking the capturer's device, but test with the web-console GPU preference pointed at each GPU.
  • The AMF runtime ships with the AMD driver, not with us — a missing/ancient amfrt64.dll must produce a clean "install/update the AMD driver" error at open, then fall back (Phase 1) or fail the session with that message (Phase 3).

7. Phasing

Phase Scope Exit criterion
1 FFI layer + AVC/HEVC (SDR + 10-bit HDR), bounded poll, native reset(), dispatch with ffmpeg fallback + PUNKTFUNK_AMF_FFMPEG hatch §5.25.3 pass on the lab iGPU
2 Intra-refresh (caps().intra_refresh), in-band HDR SEI (supports_hdr_metadata), AV1, native codec probe field-validated on ≥2 VCN generations
3 Delete the ffmpeg-AMF dispatch arm + hatch; FFmpeg remains QSV-only one release of field silence on the fallback label