feat(host): in-place encoder rate reconfigure — ABR steps no longer cost an IDR
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m8s
ci / bench (push) Successful in 5m25s
decky / build-publish (push) Successful in 21s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 9s
arch / build-publish (push) Successful in 11m11s
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 8s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
android / android (push) Successful in 14m53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m24s
deb / build-publish (push) Successful in 11m50s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 13m5s
ci / rust (push) Successful in 23m4s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m54s
docker / deploy-docs (push) Successful in 22s
apple / swift (push) Successful in 4m31s
windows-host / package (push) Successful in 14m46s
apple / screenshots (push) Successful in 18m26s

Every adaptive-bitrate step used to tear the encoder down and rebuild it,
opening on a full IDR (a 20-40x frame-size spike, in-flight AU forfeit and
an IDR-cooldown anchor) — exactly when the Automatic controller is climbing.
Encoder::reconfigure_bitrate(bps) retargets the LIVE encoder instead
(default false, so libavcodec/software paths keep the rebuild fallback,
which also still owns the bitrate clamping):

- Linux + Windows direct NVENC: nvEncReconfigureEncoder (added to the
  hand-rolled runtime EncodeApi tables) with resetEncoder=0 / forceIDR=0;
  the same init/config is re-authored via the new shared build_config/
  build_init_params with only avg/max bitrate + VBV (PUNKTFUNK_VBV_FRAMES)
  moved. On-hardware test: 20→60→10 Mbps in place, zero IDRs (RTX 5070 Ti).
- Native AMF: TargetBitrate/PeakBitrate/VBVBufferSize are dynamic
  properties — SetProperty on the live component, no Terminate/re-Init.
- Vulkan Video (HEVC + AV1): stage the rate and emit an
  ENCODE_RATE_CONTROL control command on the next recorded frame (begin
  keeps declaring the session's current state, as the spec requires).

The session glue tries the in-place retarget first and skips the rebuild/
inflight-clear/IDR-cooldown bookkeeping when it succeeds — the reference
chain and the wire-index prediction survive, so RFI keeps working across
rate steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:53:18 +02:00
parent 46b7ffc001
commit a1af916e38
6 changed files with 478 additions and 93 deletions
@@ -1328,6 +1328,14 @@ impl AmfEncoder {
!ltr_disabled() && matches!(self.codec, Codec::H264 | Codec::H265)
}
/// The VBV/HRD buffer (bits) at `bps`: ~1 frame interval, `PUNKTFUNK_VBV_FRAMES`-scaled — the
/// same shape every backend ships. Shared by [`apply_static_props`](Self::apply_static_props)
/// and [`Encoder::reconfigure_bitrate`] so a dynamic retarget rescales the buffer it opened with.
fn vbv_bits(&self, bps: u64) -> i64 {
((bps as f64 / self.fps.max(1) as f64) * crate::encode::vbv_frames_env())
.clamp(1.0, i32::MAX as f64) as i64
}
/// 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).
@@ -1357,14 +1365,12 @@ impl AmfEncoder {
true,
)?;
// ~1-frame VBV (PUNKTFUNK_VBV_FRAMES override, same knob as the ffmpeg path).
let vbv_frames = std::env::var("PUNKTFUNK_VBV_FRAMES")
.ok()
.and_then(|s| s.parse::<f32>().ok())
.filter(|v| v.is_finite() && *v > 0.0)
.unwrap_or(1.0);
let vbv_bits = ((self.bitrate_bps as f64 / self.fps.max(1) as f64) * vbv_frames as f64)
.clamp(1.0, i32::MAX as f64) as i64;
set_prop(comp, p.vbv_size, AmfVariant::from_i64(vbv_bits), false)?;
set_prop(
comp,
p.vbv_size,
AmfVariant::from_i64(self.vbv_bits(self.bitrate_bps)),
false,
)?;
set_prop(comp, p.enforce_hrd, AmfVariant::from_bool(true), false)?;
set_prop(comp, p.filler_data, AmfVariant::from_bool(false), false)?;
// Latency-first quality; low-latency submission mode (optional — newer VCN/drivers).
@@ -2499,6 +2505,47 @@ impl Encoder for AmfEncoder {
true
}
fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
let bps_i = bps.min(i64::MAX as u64) as i64;
let vbv = self.vbv_bits(bps);
let Some(inner) = self.inner.as_ref() else {
// Nothing live yet — the lazy open applies the new rate via `apply_static_props`.
self.bitrate_bps = bps;
return true;
};
// `TargetBitrate`/`PeakBitrate`/`VBVBufferSize` are DYNAMIC AMF properties (runtime-
// changeable on AVC/HEVC/AV1 alike): a SetProperty on the live component retargets the
// rate controller with no Terminate/re-Init — the reference chain, LTR slots and
// in-flight frames all survive (no IDR).
// SAFETY: `inner.comp.0` is the live component, used only on this thread with no AMF
// call in flight (the session loop is synchronous); `set_prop` is a prefix-vtable call.
let applied = unsafe {
let p = &self.props;
let comp = inner.comp.0;
let ok = set_prop(comp, p.target_bitrate, AmfVariant::from_i64(bps_i), false)
.unwrap_or(false)
&& set_prop(comp, p.peak_bitrate, AmfVariant::from_i64(bps_i), false)
.unwrap_or(false);
if ok {
// Rescale the VBV with the rate. Optional, like at open — a driver that declines
// keeps the old buffer (a size mismatch the HRD absorbs), not worth a rebuild.
let _ = set_prop(comp, p.vbv_size, AmfVariant::from_i64(vbv), false);
}
ok
};
if !applied {
// A half-applied pair doesn't matter: the caller's rebuild fallback re-authors
// everything from scratch.
tracing::warn!(
mbps = bps / 1_000_000,
"AMF declined the dynamic bitrate retarget — falling back to a rebuild"
);
return false;
}
self.bitrate_bps = bps; // future reset()/re-Init paths re-apply the new rate
true
}
fn flush(&mut self) -> Result<()> {
let Some(inner) = self.inner.as_mut() else {
return Ok(());