video: reuse the swscale context + gate per-frame decode tracing

get_frame rebuilt the RGBA swscale context on every decoded frame and printed
[Video Timing] lines unconditionally. A stream's frames share one input
format/size, so build the scaler once (keyed on format+dims, rebuilt only if
they change) and reuse it; gate the per-frame traces behind LB_VIDEO_DEBUG.

Cuts export wall time ~10% on a 1080p video clip (the scaler rebuild was the
bulk of the per-frame "scale" cost; it's now ~0ms). SwsContext is !Send, so the
cached scaler is wrapped in a SendScaler — sound because a VideoDecoder is only
ever touched under the VideoManager mutex (same invariant as its decoder/input).
This commit is contained in:
Skyler Lehmkuhl 2026-06-25 22:44:14 -04:00
parent e7c239f203
commit 7525a604f2
1 changed files with 54 additions and 18 deletions

View File

@ -97,6 +97,22 @@ pub struct VideoDecoder {
decoder: Option<ffmpeg::decoder::Video>,
last_decoded_ts: i64, // Track the last decoded frame timestamp
keyframe_positions: Vec<i64>, // Index of keyframe timestamps for fast seeking
/// Reused RGBA scaler, keyed by the input (format, width, height). Building an swscale
/// context is not free; a stream's frames share one format/size, so build it once.
scaler: Option<(ffmpeg::format::Pixel, u32, u32, SendScaler)>,
}
/// `SwsContext` is `!Send` in ffmpeg-next, but a `VideoDecoder` (like its decoder/input) is only
/// ever accessed under the `VideoManager` mutex — never concurrently — so moving it between
/// threads is sound. The decoder/input fields rely on the same invariant.
struct SendScaler(ffmpeg::software::scaling::context::Context);
unsafe impl Send for SendScaler {}
/// Per-frame video decode tracing, gated behind `LB_VIDEO_DEBUG` (checked once). Off by
/// default — at export frame rates these prints are a lot of locked stderr writes.
fn video_debug() -> bool {
static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*V.get_or_init(|| std::env::var("LB_VIDEO_DEBUG").is_ok())
}
impl VideoDecoder {
@ -183,6 +199,7 @@ impl VideoDecoder {
decoder: None,
last_decoded_ts: -1,
keyframe_positions,
scaler: None,
})
}
@ -270,7 +287,9 @@ impl VideoDecoder {
// Check cache
if let Some(cached_frame) = self.frame_cache.get(&frame_ts) {
eprintln!("[Video Timing] Cache hit for ts={:.3}s ({}ms)", timestamp, t_start.elapsed().as_millis());
if video_debug() {
eprintln!("[Video Timing] Cache hit for ts={:.3}s ({}ms)", timestamp, t_start.elapsed().as_millis());
}
return Ok(cached_frame.clone());
}
@ -292,8 +311,10 @@ impl VideoDecoder {
let keyframe_seconds = keyframe_ts_stream as f64 * self.time_base;
let keyframe_ts_av = (keyframe_seconds * 1_000_000.0) as i64; // AV_TIME_BASE = 1000000
eprintln!("[Video Seek] Target: {} | Keyframe(stream): {} | Keyframe(AV): {} | Index size: {}",
frame_ts, keyframe_ts_stream, keyframe_ts_av, self.keyframe_positions.len());
if video_debug() {
eprintln!("[Video Seek] Target: {} | Keyframe(stream): {} | Keyframe(AV): {} | Index size: {}",
frame_ts, keyframe_ts_stream, keyframe_ts_av, self.keyframe_positions.len());
}
// Reopen input (a fresh BlobReader for packed sources).
let mut owned = self.source.open()
@ -306,7 +327,9 @@ impl VideoDecoder {
input.seek(keyframe_ts_av, keyframe_ts_av..(keyframe_ts_av + 1))
.map_err(|e| format!("Seek failed: {}", e))?;
eprintln!("[Video Timing] Seek call took {}ms", t_seek_start.elapsed().as_millis());
if video_debug() {
eprintln!("[Video Timing] Seek call took {}ms", t_seek_start.elapsed().as_millis());
}
let context_decoder = ffmpeg::codec::context::Context::from_parameters(
input.streams().best(ffmpeg::media::Type::Video).unwrap().parameters()
@ -354,16 +377,27 @@ impl VideoDecoder {
if is_better {
let t_scale_start = Instant::now();
// Convert to RGBA and scale to output size
let mut scaler = ffmpeg::software::scaling::context::Context::get(
frame.format(),
frame.width(),
frame.height(),
ffmpeg::format::Pixel::RGBA,
self.output_width,
self.output_height,
ffmpeg::software::scaling::flag::Flags::BILINEAR,
).map_err(|e| e.to_string())?;
// Reuse the RGBA scaler across frames; rebuild only if the input
// format/size changes (it won't within a stream).
let need_new = match &self.scaler {
Some((fmt, w, h, _)) => {
*fmt != frame.format() || *w != frame.width() || *h != frame.height()
}
None => true,
};
if need_new {
let ctx = ffmpeg::software::scaling::context::Context::get(
frame.format(),
frame.width(),
frame.height(),
ffmpeg::format::Pixel::RGBA,
self.output_width,
self.output_height,
ffmpeg::software::scaling::flag::Flags::BILINEAR,
).map_err(|e| e.to_string())?;
self.scaler = Some((frame.format(), frame.width(), frame.height(), SendScaler(ctx)));
}
let scaler = &mut self.scaler.as_mut().unwrap().3.0;
let mut rgb_frame = ffmpeg::util::frame::Video::empty();
scaler.run(&frame, &mut rgb_frame)
@ -392,10 +426,12 @@ impl VideoDecoder {
if current_frame_ts >= frame_ts {
// Found our frame, cache and return it
if let Some(data) = best_frame_data {
let total_time = t_start.elapsed().as_millis();
let decode_time = t_decode_start.elapsed().as_millis();
eprintln!("[Video Timing] ts={:.3}s | Decoded {} frames in {}ms | Scale: {}ms | Total: {}ms",
timestamp, decode_count, decode_time, scale_time_ms, total_time);
if video_debug() {
let total_time = t_start.elapsed().as_millis();
let decode_time = t_decode_start.elapsed().as_millis();
eprintln!("[Video Timing] ts={:.3}s | Decoded {} frames in {}ms | Scale: {}ms | Total: {}ms",
timestamp, decode_count, decode_time, scale_time_ms, total_time);
}
self.frame_cache.put(frame_ts, data.clone());
return Ok(data);
}