render: fix 4K playback memory growth and decode-to-target-size

Root causes found for the 4K stalls and the second-footage memory
blowup (audit + code review):

- ticket bookkeeping leaked unbounded: the procpool ticket table and
  the arena slot map only ever grew (50-100 tickets/sec during
  playback, each pinning montage params and shm region views).
  Completed/cancelled/superseded/crashed entries are now removed, and
  the arena reaps fire-and-forget tickets once finished; the sync poll
  path reaps via a terminal result() read. InFlight duplicate submits
  now answer State immediately instead of sitting in the map forever.
- decode ran a full-resolution swscale to F32 RGBA (~132 MB at 4K)
  plus a second full-res copy before downscaling to the 480px proxy:
  RetrieveVideoParams.target_size lets swscale convert AND resize in
  one pass (bilinear, matching the old Rust resampler), so a 4K
  preview frame costs ~1 MB instead of ~260 MB of churn. This applies
  to proxy AND full-res requests alike.
- per-process decoder cache was unbounded (each session pins an FFmpeg
  context + 2 native decoded frames): LRU-capped at 16, eviction drops
  the map entry (in-flight renders keep their Arc; Drop releases
  FFmpeg).
- playback window completions were not generation-gated: a stale
  render from before an edit landed in the rebuilt window (wrong frame
  displayed, fresh request blocked). Stale completions now return
  their shm slot credit instead.
- async audio prefetch used the polling ticket submit without ever
  polling: switched to the fire-and-forget submit so entries reap.
This commit is contained in:
2026-08-26 01:31:10 +08:00
parent ea9b451d0b
commit fa3951344b
10 changed files with 182 additions and 49 deletions
+8
View File
@@ -136,6 +136,13 @@ pub struct RetrieveVideoParams {
pub mode: RenderMode,
/// Frame alpha channel is premultiplied.
pub alpha_is_premultiplied: bool,
/// Target output size for the scaled frame: `Some((w, h))` lets the
/// decoder's swscale pass convert AND resize in one step (native
/// yuv → RGBA/F32 at the target size), skipping the full-resolution
/// float intermediate (a 4K frame is ~132 MB as F32 RGBA — decoding
/// to a 480px preview through it costs ~260 MB of churn per frame).
/// `None` keeps the native size (the old behavior).
pub target_size: Option<(u32, u32)>,
}
/// `Decoder::RetrieveAudioStatus` — outcome of an audio retrieve.
@@ -708,6 +715,7 @@ mod tests_unimplemented {
image_sequence_number: 0,
mode: RenderMode::Offline,
alpha_is_premultiplied: false,
target_size: None,
};
assert!(d.retrieve_video_frame(&p).is_err());
assert!(d.retrieve_video(&p).is_err());
+17 -4
View File
@@ -332,7 +332,7 @@ impl Decoder for FFmpegDecoder {
let f = decoded?
.ok_or_else(|| fail("no video frame available at the requested time"))?;
let (w, h, bytes) = state.scale_video_to_f32(f, p.force_range)?;
let (w, h, bytes) = state.scale_video_to_f32(f, p.force_range, p.target_size)?;
let frame = copy_rgba_f32_to_frame(w, h, &bytes, p.time)?;
Ok(Arc::new(frame))
}
@@ -908,10 +908,15 @@ impl DecoderState {
/// Scale a decoded frame to float RGBA (F32, 4 channels), returning the
/// raw pixel bytes plus dimensions. Mirrors `pre_process_frame` +
/// `retrieve_video_frame_internal` scaling with the color-range forcing.
/// `target_size` resizes in the same swscale pass (native → RGBA/F32 at
/// the target size) instead of converting at native size first — the
/// caller's downscale then degenerates to a plain copy, and no
/// full-resolution float intermediate (~132 MB at 4K) ever exists.
fn scale_video_to_f32(
&mut self,
f: ffmpeg::frame::Video,
force_range: i32,
target_size: Option<(u32, u32)>,
) -> crate::error::Result<(u32, u32, Vec<u8>)> {
let video = self
.video
@@ -929,7 +934,12 @@ impl DecoderState {
ffmpeg::color::Range::MPEG
});
let (w, h) = (f.width(), f.height());
let (src_w, src_h) = (f.width(), f.height());
// 目标尺寸(None = 原生;0 边回退原生,swscale 不接受 0)。
let (w, h) = match target_size {
Some((tw, th)) if tw > 0 && th > 0 => (tw, th),
_ => (src_w, src_h),
};
// swscale cannot reliably output float RGBA on every build:
// float output is missing from some static FFmpeg swscale
@@ -945,7 +955,7 @@ impl DecoderState {
} else {
(Pixel::RGBA, false)
};
let ctx = get_or_create_scaler(&mut video.scaler, src_format, w, h, out_fmt, w, h)?;
let ctx = get_or_create_scaler(&mut video.scaler, src_format, src_w, src_h, out_fmt, w, h)?;
let mut out = ffmpeg::frame::Video::empty();
ctx.run(&f, &mut out).map_err(ffmpeg_err)?;
let stride = out.stride(0);
@@ -1389,6 +1399,8 @@ fn get_or_create_scaler(
None => true,
};
if recreate {
// BILINEAR:与渲染侧原 Rust 双线性重采样器等效(POINT 会
// 明显锯齿);同尺寸纯格式转换时滤波器不参与运算。
let ctx = scaling::Context::get(
src_format,
src_width,
@@ -1396,7 +1408,7 @@ fn get_or_create_scaler(
dst_format,
dst_width,
dst_height,
scaling::Flags::POINT,
scaling::Flags::BILINEAR,
)
.map_err(ffmpeg_err)?;
*cache = Some(ScalingCache {
@@ -2448,6 +2460,7 @@ mod tests {
image_sequence_number: 0,
mode: crate::decoder::RenderMode::Offline,
alpha_is_premultiplied: false,
target_size: None,
}
}
+1
View File
@@ -242,6 +242,7 @@ mod tests {
image_sequence_number: 0,
mode: crate::decoder::RenderMode::Offline,
alpha_is_premultiplied: false,
target_size: None,
}
}
+1
View File
@@ -54,6 +54,7 @@ fn video_params(stream: CodecStream, time: Rational) -> RetrieveVideoParams {
image_sequence_number: 0,
mode: RenderMode::Offline,
alpha_is_premultiplied: false,
target_size: None,
}
}