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>
15 KiB
Native AMF encoder — handoff design
Status: PROPOSED (handoff, 2026-07-06). Design + decision record for replacing the libavcodec
*_amfpath with a direct AMF SDK encoder on Windows AMD (encode/windows/amf.rs, the AMD analogue of the direct-SDKencode/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 inencode/windows/ffmpeg_win.rs.
1. Why (measured, not speculative)
Three independent reasons, in order of weight:
- The libavcodec AMF wrapper's structural ~2-frame output hold.
hevc_amf/av1_amfneed 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, everyusagepreset, and any spin budget (a 150 ms poll spin provably never produced the owed AU — it pegged at exactly 150 ms). See thepolldoc comment inffmpeg_win.rs. The direct-SDK NVENC path retrieves in ~1–2 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. - Silent driver wedges surface as forever-EAGAIN, not errors. The field failure
(AMD/Intel streams freezing after ~3–5 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_RESULTcodes (AMF_INPUT_FULL, device-lost, etc.) — a native path sees the wedge on the frame it happens. - Feature gaps libavcodec cannot express. No intra-refresh wave (every
FEC-unrecoverable loss is answered with a full IDR — the 20–40× frame-size spike the
Linux NVENC intra-refresh mode exists to avoid), no in-band HDR mastering SEI
(
EncoderCaps::supports_hdr_metadatais 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_powerVDEnc 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-qsvbuild 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 pinnedAMF_FULL_VERSIONwe mirror headers from) andGetProcAddress("AMFInit")→AMFFactory*. factory->CreateContext→context->InitDX11(capturer_device, AMF_DX11_1)— the capturer's ownID3D11Device, same-device requirement as every other backend (the capture textures are not shared-handle; see theensure_inner_d3d11rebind logic inffmpeg_win.rsfor the device-change lifecycle to replicate).factory->CreateComponent(context, name)withAMFVideoEncoderVCE_AVC/AMFVideoEncoder_HEVC/AMFVideoEncoder_AV1→encoder->Init(AMF_SURFACE_NV12|P010, w, h).- Per-frame:
context->CreateSurfaceFromDX11Native(texture, &surface, observer)→ per-surface properties (pts viaSetPts, forced-IDR picture type) →encoder->SubmitInput(surface); retrieve viaencoder->QueryOutput(&data)(AMF_REPEAT= not ready),AMFBuffer::GetNative/GetSize→EncodedFrame. - Every mirrored struct/call carries a
// SAFETY:proof — the whole encode module tree is under#.
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 ~1–5 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)
SubmitInput→AMF_INPUT_FULL: returnErrfromsubmit— the session loop's submit-failure path runs the bounded in-place reset. Any other non-OK result:Err.QueryOutput→AMF_REPEAT: keep spinning within the poll budget, thenOk(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-Initon 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: trueonce Phase 2 lands (this flag is what makes the session glue rate-limit client keyframe requests — see theIDR_WINDOWlogic in punktfunk1.rs),supports_hdr_metadata: trueonce the SEI lands,chroma_444: falsepermanently (VCN hardware does not encode 4:4:4;probe_can_encode_444stays 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::Amfarm: tryamf::AmfEncoder::openfirst; onErr,tracing::warn!+ fall back toffmpeg_win(when theamf-qsvfeature is built) — the same graceful-degrade shape as zero-copy→system today. Escape hatch:PUNKTFUNK_AMF_FFMPEG=1skips the native path (field triage). Phase 3 deletes both the fallback arm and the hatch.resolved_backend_label/crate::gpusession 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) andwindows_codec_support: replace the ffmpeg open-probe with a native factory probe (CreateComponentper 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:
- Open/probe smoke per codec (AVC, HEVC, HEVC-10) on the iGPU.
- A/B the encode latency: expect
encode_usp50 ~2 frame periods → ≤ 1 frame period (see §3.3 for whywait_usis 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_usp50 2.7–2.9 ms (system readback) → 0.26 ms (zero-copy D3D11), p99 6.6 ms → 0.5 ms. - 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 lineencode stall detectedmust NOT appear). - 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 headlessCreateComponentprobe (skips cleanly on non-AMD boxes, like the NVENC live-gated tests). - Per-frame
CreateSurfaceFromDX11Nativeallocation 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.dllmust 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.2–5.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 |