feat(recovery): clean mid-stream loss recovery — freeze-until-reanchor + AMD LTR-RFI
ci / rust (push) Failing after 53s
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 58s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
apple / swift (push) Successful in 4m57s
ci / bench (push) Successful in 5m43s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m4s
docker / deploy-docs (push) Successful in 21s
windows-host / package (push) Failing after 8m42s
flatpak / build-publish (push) Failing after 8m6s
android / android (push) Successful in 11m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m59s
arch / build-publish (push) Successful in 13m20s
deb / build-publish (push) Successful in 14m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m1s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m28s
release / apple (push) Successful in 25m54s
apple / screenshots (push) Successful in 19m35s
ci / rust (push) Failing after 53s
ci / docs-site (push) Successful in 54s
ci / web (push) Successful in 58s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
decky / build-publish (push) Successful in 18s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 7s
apple / swift (push) Successful in 4m57s
ci / bench (push) Successful in 5m43s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m4s
docker / deploy-docs (push) Successful in 21s
windows-host / package (push) Failing after 8m42s
flatpak / build-publish (push) Failing after 8m6s
android / android (push) Successful in 11m59s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m59s
arch / build-publish (push) Successful in 13m20s
deb / build-publish (push) Successful in 14m36s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m45s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m21s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m1s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 5m28s
release / apple (push) Successful in 25m54s
apple / screenshots (push) Successful in 19m35s
Removes the "gray frames with motion" artifact on Vulkan-Video clients and lets AMD/NVENC hosts re-anchor after loss WITHOUT a 20-40x IDR spike. Client (pf-client-core): after a reference loss the hardware decoder conceals the missing-reference deltas (on RADV, a gray plate with new motion painted over) and returns Ok. The pump now freezes on the last good picture until a clean re-anchor instead of showing the concealment — lifting on a real IDR, an intra-refresh recovery mark (2nd wave boundary), or an LTR-RFI recovery anchor (1st). The frame_index gap is the early, precise loss signal and drives an RFI request. Host recovery signals (inert unless the backend supports them): - USER_FLAG_RECOVERY_POINT — intra-refresh wave boundary (NVENC constrained GDR). - USER_FLAG_RECOVERY_ANCHOR — AMD LTR reference-frame-invalidation recovery frame. AMD LTR-RFI (encode/windows/amf.rs) — the AMD twin of NVENC RFI. AMF's AVC/HEVC API has no constrained-intra property (intra-refresh cannot heal; PSNR-proven), so the only clean-recovery lever is user LTR: mark frames as long-term references, and on loss force the next frame to re-reference the newest known-good one — a clean P-frame, not an IDR. Two rotating LTR slots, ~0.5s mark cadence, on by default for AVC/HEVC (PUNKTFUNK_NO_AMF_LTR disables). invalidate_ref_frames picks the newest LTR before the loss; a range older than the live slots falls back to a keyframe. Protocol (punktfunk-core): RfiRequest control message + NativeClient::request_rfi(). Host: RfiRequest dispatch -> invalidate_ref_frames (IDR fallback); an RFI success anchors the keyframe cooldown so the client's frames_dropped echo of the same loss is coalesced away rather than emitting a redundant IDR. Spike: synthetic NV12 GPU source for headless AMF encoder testing. Validated: core rfi_request_roundtrip; pf-client-core 31 unit tests (incl. an_rfi_anchor_lifts_immediately); punktfunk-host builds + 271 tests on Linux; punktfunk-host builds clean on Windows; real AMD iGPU spike (invalidate at frame 90 forced re-reference to LTR frame 60 — 180 frames, keyframes=1, no recovery IDR). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -177,6 +177,10 @@ pub struct NvencEncoder {
|
||||
/// Opened in intra-refresh mode (surfaced via [`caps`](Encoder::caps) so the session glue
|
||||
/// rate-limits forced IDRs — the wave heals loss without them).
|
||||
intra_refresh: bool,
|
||||
/// Resolved wave length in frames when [`intra_refresh`](Self::intra_refresh), else 0. Cached at
|
||||
/// open so the pump's per-AU `caps()` doesn't re-read `PUNKTFUNK_IR_PERIOD_FRAMES`; the pump marks
|
||||
/// every Nth AU with `USER_FLAG_RECOVERY_POINT` for the client's clean re-anchor.
|
||||
intra_refresh_period: u32,
|
||||
}
|
||||
|
||||
// `CudaHw` holds raw `AVBufferRef`s and `sws_444` a raw `SwsContext`; the encoder lives on a single
|
||||
@@ -525,6 +529,11 @@ impl NvencEncoder {
|
||||
frame_idx: 0,
|
||||
force_kf: false,
|
||||
intra_refresh,
|
||||
intra_refresh_period: if intra_refresh {
|
||||
intra_refresh_period(fps).max(1) as u32
|
||||
} else {
|
||||
0
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -536,6 +545,12 @@ impl Encoder for NvencEncoder {
|
||||
// convert. RFI/HDR-SEI stay unsupported on libavcodec NVENC (the trait defaults).
|
||||
chroma_444: self.want_444,
|
||||
intra_refresh: self.intra_refresh,
|
||||
// NVENC intra-refresh is purpose-built GDR loss recovery (moving band + recovery-point
|
||||
// SEI): the wave heals a lost picture within one period, so mark the boundary AUs and let
|
||||
// the client re-anchor on them instead of forcing a full IDR. Tied to `intra_refresh`
|
||||
// (already the `PUNKTFUNK_INTRA_REFRESH` opt-in), unlike AMF/QSV which stay unvalidated.
|
||||
intra_refresh_recovery: self.intra_refresh,
|
||||
intra_refresh_period: self.intra_refresh_period,
|
||||
..super::EncoderCaps::default()
|
||||
}
|
||||
}
|
||||
@@ -578,6 +593,7 @@ impl Encoder for NvencEncoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
// No packet ready yet (need another input frame).
|
||||
|
||||
@@ -294,6 +294,7 @@ fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<Option<En
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
|
||||
@@ -211,6 +211,7 @@ impl Encoder for OpenH264Encoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: false,
|
||||
});
|
||||
}
|
||||
self.frame_idx += 1;
|
||||
|
||||
@@ -644,6 +644,26 @@ struct CodecProps {
|
||||
/// HEVC 64-px CTBs. `None` on AV1 (v1.4.36 exposes only a mode enum, no slot-size control —
|
||||
/// loss recovery stays IDR there).
|
||||
intra_refresh: Option<(PCWSTR, u32)>,
|
||||
/// LTR-RFI recovery property names (design: the AMD twin of NVENC intra-refresh recovery).
|
||||
/// `None` on AV1 — its reference management uses a frame-marking OBU mechanism this path does
|
||||
/// not drive, so LTR recovery is AVC/HEVC-only.
|
||||
ltr: Option<LtrProps>,
|
||||
}
|
||||
|
||||
/// The four AMF LTR (long-term-reference) property names, codec-prefixed (AVC bare, HEVC `Hevc*`).
|
||||
/// Two are static (`max_*`, set once at open); two are per-frame (`mark`/`force`, set on the input
|
||||
/// surface each `submit`). Together they let a loss re-reference a known-good older frame — a clean
|
||||
/// P-frame instead of a 20–40× IDR spike.
|
||||
struct LtrProps {
|
||||
/// `MaxOfLTRFrames` — number of user LTR slots (we request [`NUM_LTR_SLOTS`]).
|
||||
max_ltr_frames: PCWSTR,
|
||||
/// `MaxNumRefFrames` — reference-picture budget; must exceed 1 for LTR to engage.
|
||||
max_num_ref_frames: PCWSTR,
|
||||
/// `MarkCurrentWithLTRIndex` (per-frame) — tag the current frame as long-term reference slot N.
|
||||
mark_ltr_index: PCWSTR,
|
||||
/// `ForceLTRReferenceBitfield` (per-frame) — force the current frame to reference only the LTR
|
||||
/// slots in the bitfield (`1<<N`), breaking the corrupted short-term chain after a loss.
|
||||
force_ltr_bitfield: PCWSTR,
|
||||
}
|
||||
|
||||
/// The two payload shapes `lowlatency` takes across codecs.
|
||||
@@ -689,6 +709,12 @@ fn codec_props(codec: Codec) -> CodecProps {
|
||||
out_primaries: w!("OutColorPrimaries"),
|
||||
hdr_metadata: None,
|
||||
intra_refresh: Some((w!("IntraRefreshMBsNumberPerSlot"), 16)),
|
||||
ltr: Some(LtrProps {
|
||||
max_ltr_frames: w!("MaxOfLTRFrames"),
|
||||
max_num_ref_frames: w!("MaxNumRefFrames"),
|
||||
mark_ltr_index: w!("MarkCurrentWithLTRIndex"),
|
||||
force_ltr_bitfield: w!("ForceLTRReferenceBitfield"),
|
||||
}),
|
||||
},
|
||||
Codec::H265 => CodecProps {
|
||||
component: w!("AMFVideoEncoderHW_HEVC"),
|
||||
@@ -716,6 +742,12 @@ fn codec_props(codec: Codec) -> CodecProps {
|
||||
out_primaries: w!("HevcOutColorPrimaries"),
|
||||
hdr_metadata: Some(w!("HevcInHDRMetadata")),
|
||||
intra_refresh: Some((w!("HevcIntraRefreshCTBsNumberPerSlot"), 64)),
|
||||
ltr: Some(LtrProps {
|
||||
max_ltr_frames: w!("HevcMaxOfLTRFrames"),
|
||||
max_num_ref_frames: w!("HevcMaxNumRefFrames"),
|
||||
mark_ltr_index: w!("HevcMarkCurrentWithLTRIndex"),
|
||||
force_ltr_bitfield: w!("HevcForceLTRReferenceBitfield"),
|
||||
}),
|
||||
},
|
||||
Codec::Av1 => CodecProps {
|
||||
component: w!("AMFVideoEncoderHW_AV1"),
|
||||
@@ -743,6 +775,7 @@ fn codec_props(codec: Codec) -> CodecProps {
|
||||
out_primaries: w!("Av1OutputColorPrimaries"),
|
||||
hdr_metadata: Some(w!("Av1InHDRMetadata")),
|
||||
intra_refresh: None,
|
||||
ltr: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -797,6 +830,45 @@ fn intra_refresh_period(fps: u32) -> u32 {
|
||||
.unwrap_or_else(|| (fps.max(16) / 2).max(2))
|
||||
}
|
||||
|
||||
/// Number of user-controlled LTR slots. AMD exposes up to 2; two rotating slots hold a sliding pair
|
||||
/// of recent long-term references, so a loss can re-reference the newest one *before* the loss point.
|
||||
const NUM_LTR_SLOTS: usize = 2;
|
||||
|
||||
/// AMD's real clean loss-recovery path (the NVENC-RFI twin): the encoder marks frames as long-term
|
||||
/// references, and on loss forces a later frame to re-reference a known-good one — a clean P-frame,
|
||||
/// not a 20-40× IDR spike. On by default when the driver supports it (AMF intra-refresh cannot heal —
|
||||
/// no constrained-intra-prediction property exists in the API, header-confirmed + PSNR-proven — and
|
||||
/// LTR is mutually exclusive with it, so LTR wins). `PUNKTFUNK_NO_AMF_LTR=1` forces the old full-IDR
|
||||
/// recovery for debugging.
|
||||
fn ltr_disabled() -> bool {
|
||||
std::env::var("PUNKTFUNK_NO_AMF_LTR")
|
||||
.map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Cadence (frames) between LTR marks — a fresh long-term reference roughly every half second by
|
||||
/// default (`PUNKTFUNK_LTR_INTERVAL_FRAMES` overrides). With [`NUM_LTR_SLOTS`] slots this keeps ~one
|
||||
/// second of recent references, so a loss up to ~1 s old still has a known-good frame to force; a
|
||||
/// smaller interval means the forced reference is more recent (a smaller recovery-frame residual).
|
||||
fn ltr_mark_interval(fps: u32) -> i64 {
|
||||
std::env::var("PUNKTFUNK_LTR_INTERVAL_FRAMES")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v >= 1)
|
||||
.unwrap_or_else(|| (fps.max(2) / 2).max(1) as i64)
|
||||
}
|
||||
|
||||
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N` the encoder
|
||||
/// self-triggers its real [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) path, so a
|
||||
/// headless spike run can exercise LTR recovery end-to-end (mark → force → recovery-anchor tag)
|
||||
/// without a live client sending an [`RfiRequest`](punktfunk_core::quic::RfiRequest). `None` normally.
|
||||
fn ltr_test_force_at() -> Option<i64> {
|
||||
std::env::var("PUNKTFUNK_LTR_FORCE_AT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|v| *v > 0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Owned-pointer guards (release exactly once; Terminate before Release for context/component,
|
||||
// mirroring amfenc.c's teardown order).
|
||||
@@ -930,11 +1002,12 @@ struct Inner {
|
||||
dctx: ID3D11DeviceContext,
|
||||
ring: Vec<ID3D11Texture2D>,
|
||||
next: usize,
|
||||
/// (pts_ns, forced-IDR) per submitted-but-unretrieved frame, FIFO — the AMF encoder emits
|
||||
/// AUs in submit order (B-frames are never enabled), pairing with `QueryOutput`. Its length is
|
||||
/// the count of input surfaces AMF still holds, so `submit` bounds it below [`RING`] to keep
|
||||
/// the input ring from being overwritten under it.
|
||||
pending: VecDeque<(u64, bool)>,
|
||||
/// (pts_ns, forced-IDR, recovery-anchor) per submitted-but-unretrieved frame, FIFO — the AMF
|
||||
/// encoder emits AUs in submit order (B-frames are never enabled), pairing with `QueryOutput`.
|
||||
/// The third field tags the LTR-RFI re-anchor frame so the AU carries `recovery_anchor` for the
|
||||
/// client's freeze-lift. Its length is the count of input surfaces AMF still holds, so `submit`
|
||||
/// bounds it below [`RING`] to keep the input ring from being overwritten under it.
|
||||
pending: VecDeque<(u64, bool, bool)>,
|
||||
/// AUs already pulled by `submit`'s backpressure drain, waiting to be handed out by `poll`
|
||||
/// (FIFO, strictly older than anything still in `pending`). Empty in the steady state — only
|
||||
/// fills when the encoder falls behind and `submit` drains to free an input slot.
|
||||
@@ -988,6 +1061,26 @@ pub struct AmfEncoder {
|
||||
/// gates [`EncoderCaps::intra_refresh`] so keyframe-request rate-limiting only happens when
|
||||
/// the wave really runs.
|
||||
ir_active: bool,
|
||||
// --- Long-Term-Reference reference-frame-invalidation recovery (the AMD RFI path) ---
|
||||
/// The driver accepted the LTR properties at open — gates [`EncoderCaps::supports_rfi`] and all
|
||||
/// the per-frame LTR marking/forcing below. When true, intra-refresh is NOT set (mutually
|
||||
/// exclusive) and loss recovery re-references a known-good LTR instead of forcing a full IDR.
|
||||
ltr_active: bool,
|
||||
/// The `frame_idx` currently stored in each of the two LTR slots (`None` = never marked). On loss
|
||||
/// the newest slot with an index *before* the loss is the known-good reference to force.
|
||||
ltr_slots: [Option<i64>; NUM_LTR_SLOTS],
|
||||
/// The slot the next LTR mark writes (round-robins `0,1,0,1,…` so the two slots hold a sliding
|
||||
/// pair of recent references).
|
||||
next_ltr_slot: usize,
|
||||
/// Cadence (frames) between LTR marks — a fresh long-term reference roughly this often.
|
||||
ltr_mark_interval: i64,
|
||||
/// Set by [`invalidate_ref_frames`](Encoder::invalidate_ref_frames): the LTR slot the *next*
|
||||
/// submitted frame must force-reference (`ForceLTRReferenceBitfield`). Consumed on that submit.
|
||||
pending_force: Option<usize>,
|
||||
/// Validation hook (`PUNKTFUNK_LTR_FORCE_AT=N`, spike-only): at `frame_idx == N`, self-trigger the
|
||||
/// real [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) path so a headless spike run can
|
||||
/// exercise LTR recovery end-to-end without a live client. `None` in normal operation.
|
||||
ltr_test_force_at: Option<i64>,
|
||||
/// Consecutive [`reset`](Self::reset)s that have NOT been followed by a produced AU (cleared in
|
||||
/// `poll` on any output). An in-place `Terminate`+re-`Init` heals a transient component stall,
|
||||
/// but it re-inits the SAME context — so if the fault is the context / VCN session itself (the
|
||||
@@ -1084,17 +1177,33 @@ impl AmfEncoder {
|
||||
force_kf: false,
|
||||
hdr_meta: None,
|
||||
ir_active: false,
|
||||
ltr_active: false,
|
||||
ltr_slots: [None; NUM_LTR_SLOTS],
|
||||
next_ltr_slot: 0,
|
||||
ltr_mark_interval: ltr_mark_interval(fps),
|
||||
pending_force: None,
|
||||
ltr_test_force_at: ltr_test_force_at(),
|
||||
resets_without_output: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether this encoder should *attempt* the LTR-RFI recovery path (design: the AMD twin of
|
||||
/// NVENC intra-refresh recovery). Gated to AVC/HEVC — AMF exposes user LTR only for those two
|
||||
/// codecs — and defeatable via `PUNKTFUNK_NO_AMF_LTR`. Whether the driver actually *accepts* the
|
||||
/// properties is a separate question answered by [`apply_static_props`], which sets `ltr_active`.
|
||||
fn ltr_wanted(&self) -> bool {
|
||||
!ltr_disabled() && matches!(self.codec, Codec::H264 | Codec::H265)
|
||||
}
|
||||
|
||||
/// Apply the static encoder configuration (design §3.4 — the native mirror of the ffmpeg
|
||||
/// opts block in `open_win_encoder`). Called before `Init`, and again on a `reset()`
|
||||
/// re-`Init` (Terminate does not guarantee property retention across every driver).
|
||||
/// Returns whether the intra-refresh wave was requested AND accepted by this driver — the
|
||||
/// caller stores it so [`Encoder::caps`] only rate-limits keyframe requests when the wave
|
||||
/// really runs.
|
||||
unsafe fn apply_static_props(&self, comp: *mut sys::AmfComponent) -> Result<bool> {
|
||||
/// Returns `(ir_active, ltr_active)`: whether the intra-refresh wave / the LTR-RFI slots were
|
||||
/// requested AND accepted by this driver. The two are mutually exclusive (LTR wins when both are
|
||||
/// wanted). The caller stores both — `ir_active` so [`Encoder::caps`] only rate-limits keyframe
|
||||
/// requests when a wave runs, `ltr_active` so [`Encoder::caps`] advertises `supports_rfi` and the
|
||||
/// per-frame mark/force logic in `submit` only fires when the slots exist.
|
||||
unsafe fn apply_static_props(&self, comp: *mut sys::AmfComponent) -> Result<(bool, bool)> {
|
||||
let p = &self.props;
|
||||
// Usage first: it "fully configures parameter set" — everything after is an override.
|
||||
set_prop(
|
||||
@@ -1145,7 +1254,39 @@ impl AmfEncoder {
|
||||
// whole picture refreshes every `period` frames — per-slot units = ceil(total blocks /
|
||||
// period). Optional by VCN generation; the return value gates `caps().intra_refresh`.
|
||||
let mut ir_active = false;
|
||||
if let Some((name, block)) = p.intra_refresh {
|
||||
let mut ltr_active = false;
|
||||
if let Some(ltr) = p.ltr.as_ref().filter(|_| self.ltr_wanted()) {
|
||||
// LTR-RFI recovery (design: the AMD twin of NVENC intra-refresh recovery). Request
|
||||
// NUM_LTR_SLOTS user-controlled long-term references. LTR needs >1 reference frames and
|
||||
// is MUTUALLY EXCLUSIVE with intra-refresh (AMF disables one if both are set), so the
|
||||
// intra-refresh block below is skipped whenever LTR engages.
|
||||
let ref_ok = set_prop(
|
||||
comp,
|
||||
ltr.max_num_ref_frames,
|
||||
AmfVariant::from_i64(NUM_LTR_SLOTS as i64),
|
||||
false,
|
||||
)?;
|
||||
let ltr_ok = set_prop(
|
||||
comp,
|
||||
ltr.max_ltr_frames,
|
||||
AmfVariant::from_i64(NUM_LTR_SLOTS as i64),
|
||||
false,
|
||||
)?;
|
||||
ltr_active = ref_ok && ltr_ok;
|
||||
if ltr_active {
|
||||
tracing::info!(
|
||||
slots = NUM_LTR_SLOTS,
|
||||
mark_interval = self.ltr_mark_interval,
|
||||
"AMF LTR-RFI recovery enabled (loss recovery re-references a known-good LTR, not a full IDR)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
ref_ok,
|
||||
ltr_ok,
|
||||
"this VCN/driver rejected an LTR property — loss recovery stays full-IDR"
|
||||
);
|
||||
}
|
||||
} else if let Some((name, block)) = p.intra_refresh {
|
||||
if intra_refresh_requested() {
|
||||
let period = intra_refresh_period(self.fps);
|
||||
let blocks = self.width.div_ceil(block) * self.height.div_ceil(block);
|
||||
@@ -1273,7 +1414,7 @@ impl AmfEncoder {
|
||||
AmfVariant::from_i64(primaries),
|
||||
self.ten_bit,
|
||||
)?;
|
||||
Ok(ir_active)
|
||||
Ok((ir_active, ltr_active))
|
||||
}
|
||||
|
||||
/// Build (or rebuild, on a capture-device change) the AMF context + encoder component on the
|
||||
@@ -1323,7 +1464,7 @@ impl AmfEncoder {
|
||||
bail!("AMF CreateComponent returned null");
|
||||
}
|
||||
let comp = Component(comp);
|
||||
let ir_active = self.apply_static_props(comp.0)?;
|
||||
let (ir_active, ltr_active) = self.apply_static_props(comp.0)?;
|
||||
let fmt = if self.ten_bit {
|
||||
sys::AMF_SURFACE_P010
|
||||
} else {
|
||||
@@ -1334,6 +1475,14 @@ impl AmfEncoder {
|
||||
"AMF encoder Init",
|
||||
)?;
|
||||
self.ir_active = ir_active;
|
||||
// A rebuilt component starts with fresh (empty) LTR slots — a new context has no
|
||||
// reference history, so any prior marks are void and the first frame re-IDRs anyway.
|
||||
self.ltr_active = ltr_active;
|
||||
if ltr_active {
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
}
|
||||
|
||||
// Owned input ring on the capturer's device (design §3.2): RENDER_TARGET |
|
||||
// SHADER_RESOURCE, the same bind flags the validated ffmpeg zero-copy pool uses.
|
||||
@@ -1594,7 +1743,7 @@ enum DrainOutcome {
|
||||
/// single encode thread with no other AMF call to this component in flight.
|
||||
unsafe fn drain_one_output(
|
||||
comp: *mut sys::AmfComponent,
|
||||
pending: &mut VecDeque<(u64, bool)>,
|
||||
pending: &mut VecDeque<(u64, bool, bool)>,
|
||||
output_data_type: PCWSTR,
|
||||
output_key_max: i64,
|
||||
) -> Result<DrainOutcome> {
|
||||
@@ -1641,11 +1790,12 @@ unsafe fn drain_one_output(
|
||||
bail!("AMF output buffer is empty");
|
||||
}
|
||||
let au = std::slice::from_raw_parts(native as *const u8, size).to_vec();
|
||||
let (pts_ns, forced) = pending.pop_front().unwrap_or((0, false));
|
||||
let (pts_ns, forced, recovery_anchor) = pending.pop_front().unwrap_or((0, false, false));
|
||||
Ok(DrainOutcome::Frame(EncodedFrame {
|
||||
data: au,
|
||||
pts_ns,
|
||||
keyframe: key_prop || forced,
|
||||
recovery_anchor,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -1689,9 +1839,57 @@ impl Encoder for AmfEncoder {
|
||||
expected
|
||||
);
|
||||
self.ensure_inner(&frame.device)?;
|
||||
let cur_idx = self.frame_idx;
|
||||
let forced = std::mem::take(&mut self.force_kf) || self.frame_idx == 0;
|
||||
let pts_100ns = self.frame_idx * 10_000_000 / self.fps.max(1) as i64;
|
||||
self.frame_idx += 1;
|
||||
// --- LTR-RFI per-frame decisions (design: the AMD twin of NVENC intra-refresh recovery) ---
|
||||
// Decided here, before borrowing `inner`, because the test hook re-enters `&mut self`
|
||||
// (`invalidate_ref_frames`) and the mark cadence mutates the slot bookkeeping. The two
|
||||
// per-frame property names are copied out (PCWSTR is Copy) so the unsafe surface block can
|
||||
// set them without re-borrowing `self.props` under the live `inner` borrow.
|
||||
let ltr_names = self
|
||||
.props
|
||||
.ltr
|
||||
.as_ref()
|
||||
.map(|l| (l.mark_ltr_index, l.force_ltr_bitfield));
|
||||
let mut mark_slot: Option<usize> = None;
|
||||
let mut force_slot: Option<usize> = None;
|
||||
let mut recovery_anchor = false;
|
||||
if self.ltr_active {
|
||||
if forced {
|
||||
// An IDR resets the decoder's reference buffers — every prior LTR mark is void.
|
||||
// Re-anchor from scratch: drop the stale slots (the mark cadence below tags the IDR
|
||||
// as the first fresh long-term reference) and cancel any force queued against them.
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
} else if self.ltr_test_force_at == Some(cur_idx) {
|
||||
// Spike-only validation hook: self-trigger the real invalidate path so a headless
|
||||
// run exercises mark → force → recovery-anchor without a live client's RfiRequest.
|
||||
let triggered = self.invalidate_ref_frames(cur_idx, cur_idx);
|
||||
tracing::info!(
|
||||
frame = cur_idx,
|
||||
triggered,
|
||||
"AMF LTR test hook fired invalidate_ref_frames"
|
||||
);
|
||||
}
|
||||
// Apply a queued force (from invalidate_ref_frames / the test hook) to THIS frame: it
|
||||
// becomes the clean re-anchor P-frame the client lifts its post-loss freeze on.
|
||||
if let Some(slot) = self.pending_force.take() {
|
||||
force_slot = Some(slot);
|
||||
recovery_anchor = true;
|
||||
}
|
||||
// Mark cadence: refresh a long-term reference on every IDR and every `ltr_mark_interval`
|
||||
// frames — but never on the recovery frame itself (marking rotates `next_ltr_slot` and
|
||||
// could overwrite the very slot being forced; the next cadence mark re-establishes it).
|
||||
if force_slot.is_none() && (forced || cur_idx % self.ltr_mark_interval == 0) {
|
||||
let slot = self.next_ltr_slot;
|
||||
self.ltr_slots[slot] = Some(cur_idx);
|
||||
self.next_ltr_slot = (self.next_ltr_slot + 1) % NUM_LTR_SLOTS;
|
||||
mark_slot = Some(slot);
|
||||
}
|
||||
}
|
||||
let inner = self.inner.as_mut().expect("ensure_inner succeeded");
|
||||
// Push the HDR mastering metadata when it changed (or a rebuilt component lost it) — a
|
||||
// dynamic property, so mid-stream regrades take effect on the next IDR. Best-effort: a
|
||||
@@ -1831,6 +2029,47 @@ impl Encoder for AmfEncoder {
|
||||
Codec::Av1 => {}
|
||||
}
|
||||
}
|
||||
// LTR-RFI per-frame properties (design: the AMD twin of NVENC intra-refresh recovery).
|
||||
// `mark_slot`/`force_slot` were decided above. Marking tags the current frame as a
|
||||
// long-term reference; forcing makes it re-reference a known-good LTR — a clean P-frame
|
||||
// that breaks the corrupted short-term chain after a loss, no 20-40× IDR. Best-effort:
|
||||
// a rejecting driver just leaves the client on its keyframe-request fallback.
|
||||
if let Some((mark_name, force_name)) = ltr_names {
|
||||
if let Some(slot) = mark_slot {
|
||||
let r = ((*(*surf.0).vtbl).set_property)(
|
||||
surf.0,
|
||||
mark_name.0,
|
||||
AmfVariant::from_i64(slot as i64),
|
||||
);
|
||||
if r != sys::AMF_OK {
|
||||
tracing::warn!(
|
||||
slot,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
"AMF LTR mark rejected"
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(slot) = force_slot {
|
||||
let r = ((*(*surf.0).vtbl).set_property)(
|
||||
surf.0,
|
||||
force_name.0,
|
||||
AmfVariant::from_i64(1_i64 << slot),
|
||||
);
|
||||
if r == sys::AMF_OK {
|
||||
tracing::info!(
|
||||
slot,
|
||||
frame = cur_idx,
|
||||
"AMF LTR-RFI: re-referencing known-good LTR (clean recovery, no IDR)"
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
slot,
|
||||
result = %format!("{} ({r})", result_name(r)),
|
||||
"AMF LTR force-reference rejected — client stays frozen until its IDR fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut r = ((*(*inner.comp.0).vtbl).submit_input)(inner.comp.0, surf.0);
|
||||
// Backstop back-pressure: the in-flight bound above already keeps a slot free, but if
|
||||
// AMF's own input queue is momentarily full, AMF_INPUT_FULL is "busy, drain me and
|
||||
@@ -1873,7 +2112,7 @@ impl Encoder for AmfEncoder {
|
||||
}
|
||||
}
|
||||
}
|
||||
inner.pending.push_back((captured.pts_ns, forced));
|
||||
inner.pending.push_back((captured.pts_ns, forced, recovery_anchor));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1887,11 +2126,63 @@ impl Encoder for AmfEncoder {
|
||||
self.hdr_meta = meta;
|
||||
}
|
||||
|
||||
/// LTR-RFI recovery (the AMD twin of the Windows NVENC `nvEncInvalidateRefFrames` path): a loss
|
||||
/// of client frames `[first, last]` is answered by forcing the *next* submitted frame to
|
||||
/// re-reference the newest long-term reference marked *before* the loss — a clean P-frame the
|
||||
/// client can decode against a picture it still holds, instead of a 20-40× IDR spike.
|
||||
///
|
||||
/// Returns `true` when a usable pre-loss LTR exists (so the caller must NOT also force an IDR);
|
||||
/// `false` when the loss predates every live LTR — then the only correct recovery is a keyframe,
|
||||
/// and the caller falls back to [`request_keyframe`](Self::request_keyframe). Runs on the encode
|
||||
/// thread (like submit/poll); the force is applied on the next `submit`.
|
||||
fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
|
||||
// No live LTR session (driver declined the slots, or AV1 which has no user-LTR path) or a
|
||||
// nonsense range → caller forces a full IDR.
|
||||
if !self.ltr_active || first < 0 || first > last {
|
||||
return false;
|
||||
}
|
||||
// Pick the newest LTR strictly OLDER than the loss: the most recent known-good reference the
|
||||
// client still holds, so re-referencing it costs the least (smallest recovery-frame residual).
|
||||
// Frame numbers are 1:1 with the client's (both count submissions in order — see the NVENC
|
||||
// path), so `ltr_slots` (which store `frame_idx`) compare directly against `first`.
|
||||
let mut best: Option<(usize, i64)> = None;
|
||||
for (slot, marked) in self.ltr_slots.iter().enumerate() {
|
||||
if let Some(idx) = *marked {
|
||||
if idx < first && best.is_none_or(|(_, b)| idx > b) {
|
||||
best = Some((slot, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((slot, ltr_frame)) => {
|
||||
// Queue the force for the next submit; that frame ships tagged `recovery_anchor`.
|
||||
self.pending_force = Some(slot);
|
||||
tracing::info!(
|
||||
first,
|
||||
last,
|
||||
slot,
|
||||
ltr_frame,
|
||||
"AMF LTR-RFI: forcing the next frame to re-reference a known-good LTR (no IDR)"
|
||||
);
|
||||
true
|
||||
}
|
||||
None => {
|
||||
tracing::info!(
|
||||
first,
|
||||
last,
|
||||
"AMF LTR-RFI: no live LTR older than the loss — falling back to IDR recovery"
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn caps(&self) -> EncoderCaps {
|
||||
EncoderCaps {
|
||||
// AMF has no NVENC-style reference invalidation — the intra-refresh wave is the
|
||||
// loss-recovery substitute; without it every unrecoverable loss costs an IDR.
|
||||
supports_rfi: false,
|
||||
// LTR-RFI: AMD's reference invalidation is the user long-term-reference path (mark a
|
||||
// frame, force a later one to re-reference it). True only when the live driver accepted
|
||||
// the LTR slots at open — otherwise loss recovery falls back to a full IDR.
|
||||
supports_rfi: self.ltr_active,
|
||||
// In-band mastering/CLL via `*InHDRMetadata` (HEVC SEI / AV1 metadata OBU); AVC has
|
||||
// no such property (and no HDR sessions negotiate H.264).
|
||||
supports_hdr_metadata: self.ten_bit && self.props.hdr_metadata.is_some(),
|
||||
@@ -1901,6 +2192,11 @@ impl Encoder for AmfEncoder {
|
||||
// accepted the property (queried per loss event, so the post-first-frame value is
|
||||
// what the session glue's IDR rate-limiting sees).
|
||||
intra_refresh: self.ir_active,
|
||||
// Not yet: the AMD VCN wave heals in principle, but its constrained-GDR
|
||||
// heal-within-a-period is unvalidated on-glass and AMF emits no recovery-point SEI, so
|
||||
// the host keeps the IDR recovery path. Flip both once verified on real hardware.
|
||||
intra_refresh_recovery: false,
|
||||
intra_refresh_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1992,6 +2288,7 @@ impl Encoder for AmfEncoder {
|
||||
self.inner = None;
|
||||
self.bound_device = 0;
|
||||
self.ir_active = false;
|
||||
self.ltr_active = false;
|
||||
return true;
|
||||
}
|
||||
let inner = self
|
||||
@@ -2016,8 +2313,14 @@ impl Encoder for AmfEncoder {
|
||||
sys::AMF_SURFACE_NV12
|
||||
};
|
||||
match self.apply_static_props(comp) {
|
||||
Ok(ir) => {
|
||||
Ok((ir, ltr)) => {
|
||||
self.ir_active = ir;
|
||||
// Re-Init voids the reference history: the rebuilt stream restarts at IDR with
|
||||
// empty LTR slots, so any prior marks are stale and must be dropped.
|
||||
self.ltr_active = ltr;
|
||||
self.ltr_slots = [None; NUM_LTR_SLOTS];
|
||||
self.next_ltr_slot = 0;
|
||||
self.pending_force = None;
|
||||
((*(*comp).vtbl).init)(comp, fmt, self.width as i32, self.height as i32)
|
||||
== sys::AMF_OK
|
||||
}
|
||||
@@ -2030,6 +2333,7 @@ impl Encoder for AmfEncoder {
|
||||
);
|
||||
} else {
|
||||
self.ir_active = false;
|
||||
self.ltr_active = false;
|
||||
// Full teardown; the next submit reopens context + component on the current device.
|
||||
tracing::warn!("AMF in-place re-Init failed — full context teardown, reopening lazily");
|
||||
self.inner = None;
|
||||
|
||||
@@ -339,6 +339,7 @@ fn poll_encoder(enc: &mut encoder::video::Encoder, fps: u32) -> Result<PollOutco
|
||||
data,
|
||||
pts_ns: pts * 1_000_000_000 / fps as u64,
|
||||
keyframe: pkt.is_key(),
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
Err(ffmpeg::Error::Other { errno })
|
||||
|
||||
@@ -1157,6 +1157,7 @@ impl NvencD3d11Encoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: false,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -1424,6 +1425,8 @@ impl Encoder for NvencD3d11Encoder {
|
||||
// The direct-NVENC path recovers via real RFI (or a forced IDR), not the Linux
|
||||
// libavcodec intra-refresh mode.
|
||||
intra_refresh: false,
|
||||
intra_refresh_recovery: false,
|
||||
intra_refresh_period: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1542,6 +1545,7 @@ impl Encoder for NvencD3d11Encoder {
|
||||
data,
|
||||
pts_ns,
|
||||
keyframe,
|
||||
recovery_anchor: false,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user