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:
@@ -355,6 +355,24 @@ pub struct Reconfigured {
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RequestKeyframe;
|
||||
|
||||
/// `client → host`: reference-frame-invalidation recovery — the loss-aware sibling of
|
||||
/// [`RequestKeyframe`]. The client detected a `frame_index` gap and reports the range `[first_frame,
|
||||
/// last_frame]` of access units it can no longer trust (from the first missing index through the
|
||||
/// newest received). Instead of a full IDR (a 20-40× spike that deepens the loss it recovers), a host
|
||||
/// whose encoder supports RFI re-references a known-good picture *before* `first_frame` — an AMD LTR
|
||||
/// force-reference or an NVENC `nvEncInvalidateRefFrames` — emitting a single clean P-frame it tags
|
||||
/// [`crate::packet::USER_FLAG_RECOVERY_ANCHOR`] so the client lifts its freeze on it. A host that
|
||||
/// can't RFI (no valid reference / libavcodec backend) forces an IDR instead, exactly as for a bare
|
||||
/// [`RequestKeyframe`]; a host that predates this ignores the unknown message and the client's
|
||||
/// keyframe backstop still recovers. Fire-and-forget — the recovered frame is the only ack.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct RfiRequest {
|
||||
/// First access-unit `frame_index` the client can no longer trust (the gap start).
|
||||
pub first_frame: u32,
|
||||
/// Newest received `frame_index` at the time of the report (the invalidation range end).
|
||||
pub last_frame: u32,
|
||||
}
|
||||
|
||||
/// `client → host`, periodic: the client's observed data-plane loss, so the host can size FEC to
|
||||
/// the link instead of a flat percentage (adaptive FEC). `loss_ppm` is parts-per-million of shards
|
||||
/// that arrived missing-but-recovered (plus a bump when frames went unrecoverable) over the report
|
||||
@@ -467,6 +485,8 @@ pub const MSG_LOSS_REPORT: u8 = 0x04;
|
||||
pub const MSG_SET_BITRATE: u8 = 0x05;
|
||||
/// Type byte of [`BitrateChanged`].
|
||||
pub const MSG_BITRATE_CHANGED: u8 = 0x06;
|
||||
/// Type byte of [`RfiRequest`].
|
||||
pub const MSG_RFI_REQUEST: u8 = 0x07;
|
||||
/// Type byte of [`ProbeRequest`].
|
||||
pub const MSG_PROBE_REQUEST: u8 = 0x20;
|
||||
/// Type byte of [`ProbeResult`].
|
||||
@@ -1032,6 +1052,28 @@ impl RequestKeyframe {
|
||||
}
|
||||
}
|
||||
|
||||
impl RfiRequest {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] first_frame[5..9] last_frame[9..13]
|
||||
let mut b = Vec::with_capacity(13);
|
||||
b.extend_from_slice(CTL_MAGIC);
|
||||
b.push(MSG_RFI_REQUEST);
|
||||
b.extend_from_slice(&self.first_frame.to_le_bytes());
|
||||
b.extend_from_slice(&self.last_frame.to_le_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
pub fn decode(b: &[u8]) -> Result<RfiRequest> {
|
||||
if b.len() != 13 || &b[0..4] != CTL_MAGIC || b[4] != MSG_RFI_REQUEST {
|
||||
return Err(PunktfunkError::InvalidArg("bad RfiRequest"));
|
||||
}
|
||||
Ok(RfiRequest {
|
||||
first_frame: u32::from_le_bytes(b[5..9].try_into().unwrap()),
|
||||
last_frame: u32::from_le_bytes(b[9..13].try_into().unwrap()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl LossReport {
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
// magic[0..4] type[4] loss_ppm[5..9]
|
||||
|
||||
@@ -633,6 +633,35 @@ fn request_keyframe_roundtrip() {
|
||||
assert!(RequestKeyframe::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfi_request_roundtrip() {
|
||||
for (first_frame, last_frame) in [(0u32, 0u32), (40, 47), (5, 5), (1_000_000, u32::MAX)] {
|
||||
let r = RfiRequest {
|
||||
first_frame,
|
||||
last_frame,
|
||||
};
|
||||
assert_eq!(RfiRequest::decode(&r.encode()).unwrap(), r);
|
||||
}
|
||||
// Disjoint from the bare keyframe request (its loss-unaware sibling) and others: type byte + length.
|
||||
assert!(RfiRequest::decode(&RequestKeyframe.encode()).is_err());
|
||||
assert!(RequestKeyframe::decode(
|
||||
&RfiRequest {
|
||||
first_frame: 1,
|
||||
last_frame: 2
|
||||
}
|
||||
.encode()
|
||||
)
|
||||
.is_err());
|
||||
// Exact length — no trailing bytes.
|
||||
let bytes = RfiRequest {
|
||||
first_frame: 3,
|
||||
last_frame: 9,
|
||||
}
|
||||
.encode();
|
||||
assert!(RfiRequest::decode(&[bytes.as_slice(), &[0]].concat()).is_err());
|
||||
assert!(RfiRequest::decode(&bytes[..bytes.len() - 1]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loss_report_roundtrip() {
|
||||
for loss_ppm in [0u32, 1, 12_345, 50_000, 1_000_000] {
|
||||
|
||||
Reference in New Issue
Block a user