Skip to main content

rivet/
decode_pump.rs

1//! Shared source decode pump.
2//!
3//! One pump per job (not per rung): demux + decode the source **once**, run
4//! the rung-agnostic per-frame work (4:4:4 → 4:2:0 downsample + HDR tonemap),
5//! and fan the normalized frame out to N per-rung mpsc channels via cheap
6//! `VideoFrame::clone()` (the inner `Bytes` is `Arc`-backed).
7//!
8//! Per-rung scaling + encoding consume from those channels. Eliminating the
9//! redundant per-rung decode is the whole point — a 5-rung ladder decodes the
10//! source once, not five times. The cost: the slowest rung backpressures the
11//! pump (usually the largest rung, whose encoder is slowest).
12
13use std::time::Instant;
14
15use anyhow::{Context, Result};
16use bytes::Bytes;
17
18use codec::frame::{ColorMetadata, PixelFormat, VideoFrame};
19use codec::{colorspace, decode};
20use container::streaming;
21
22/// Configuration for one decode pump.
23#[derive(Clone)]
24pub struct DecodePumpConfig {
25    /// Source video codec label (e.g. `"h264"`).
26    pub codec_name: String,
27    /// Stream info handed to the decoder.
28    pub info_for_decoder: codec::frame::StreamInfo,
29    /// Source color metadata (drives HDR-aware tonemap vs SDR passthrough).
30    pub source_color_metadata: ColorMetadata,
31    /// Source pixel format.
32    pub source_pixel_format: PixelFormat,
33    /// Whether to run the 4:4:4 → 4:2:0 downsample per frame.
34    pub needs_downsample: bool,
35    /// Tonemap policy (from the [`OutputSpec`](crate::spec::OutputSpec)): when
36    /// `true`, HDR (PQ/HLG) sources are mapped down to 8-bit SDR BT.709; when
37    /// `false`, the source color/transfer/bit-depth passes through unchanged.
38    /// The pump does not decide this on its own — the caller sets it from the
39    /// spec's [`ColorPolicy`](crate::spec::ColorPolicy).
40    pub tonemap_to_sdr: bool,
41    /// Pin the decoder to this physical GPU; `None` = first matching adapter.
42    pub gpu_index: Option<u32>,
43    /// Prepared per-frame video filter chain (crop/pad/flip/rotate/grayscale/
44    /// overlay/colour), applied after colorspace normalize and before the frame
45    /// is fanned out to the per-rung scalers. Overlay images are loaded once at
46    /// prepare time. `Arc` so the per-GPU pump configs clone it cheaply.
47    pub filters: std::sync::Arc<codec::filter::FilterChain>,
48}
49
50/// One clip of a splice: a decode config, its source bytes, and the **source
51/// frame range** to keep. The first `start_frame` decoded frames are dropped
52/// (the trim in-point); decoding stops once the source index reaches
53/// `end_frame` (exclusive — the trim out-point). `end_frame = None` keeps the
54/// clip to its end. A single full-range clip (`start_frame = 0`,
55/// `end_frame = None`) is a plain, un-spliced transcode.
56#[derive(Clone)]
57pub struct ClipSource {
58    pub cfg: DecodePumpConfig,
59    pub input: Bytes,
60    pub start_frame: u64,
61    pub end_frame: Option<u64>,
62}
63
64impl ClipSource {
65    /// A whole clip, no trim.
66    pub fn whole(cfg: DecodePumpConfig, input: Bytes) -> Self {
67        Self { cfg, input, start_frame: 0, end_frame: None }
68    }
69}
70
71/// Single-input decode pump (no trim, no concat) — the common case. A thin
72/// wrapper over [`run_spliced_decode_pump_blocking`] with one whole clip.
73pub fn run_shared_decode_pump_blocking(
74    cfg: DecodePumpConfig,
75    input_data: Bytes,
76    senders: Vec<tokio::sync::mpsc::Sender<VideoFrame>>,
77    rt: tokio::runtime::Handle,
78) -> Result<u64> {
79    run_spliced_decode_pump_blocking(vec![ClipSource::whole(cfg, input_data)], senders, rt)
80}
81
82/// Spliced decode pump, designed for `tokio::task::spawn_blocking`. Decodes
83/// each clip in order, **drops** frames outside the clip's `[start_frame,
84/// end_frame)` source range (trim), and fans the kept frames out to all
85/// `senders` **continuously across clips** (concat). Because the muxer numbers
86/// output frames by count — not by source PTS — the join is automatically
87/// gap-free and the timeline is zero-based, with no PTS rewriting.
88///
89/// If a sender's channel is closed (its rung gave up) the pump keeps going with
90/// the rest; it stops only when *every* sender is closed. `rt` bridges into the
91/// async `send().await`. Returns the total number of frames emitted.
92pub fn run_spliced_decode_pump_blocking(
93    clips: Vec<ClipSource>,
94    senders: Vec<tokio::sync::mpsc::Sender<VideoFrame>>,
95    rt: tokio::runtime::Handle,
96) -> Result<u64> {
97    let mut total: u64 = 0;
98    let result = (|| {
99        for (clip_idx, clip) in clips.iter().enumerate() {
100            match decode_clip(clip, &senders, &rt, &mut total)
101                .with_context(|| format!("decoding splice clip {clip_idx}"))?
102            {
103                Flow::Continue => {}
104                Flow::AllReceiversClosed => break,
105            }
106        }
107        Ok(total)
108    })();
109    // Drop senders so receivers wake and exit.
110    drop(senders);
111    result
112}
113
114enum Flow {
115    Continue,
116    AllReceiversClosed,
117}
118
119/// Decode one clip, applying its trim range, fanning kept frames to `senders`
120/// and advancing the shared output counter `total`.
121fn decode_clip(
122    clip: &ClipSource,
123    senders: &[tokio::sync::mpsc::Sender<VideoFrame>],
124    rt: &tokio::runtime::Handle,
125    total: &mut u64,
126) -> Result<Flow> {
127    let cfg = &clip.cfg;
128    let mut demuxer =
129        streaming::demux_streaming(&clip.input).context("demuxing clip for decode pump")?;
130    let mut decoder =
131        decode::create_decoder_on(&cfg.codec_name, cfg.info_for_decoder.clone(), cfg.gpu_index)
132            .context("creating decoder for decode pump")?;
133
134    // Source-frame index within THIS clip — drives the trim decision.
135    let mut src_idx: u64 = 0;
136    loop {
137        match demuxer
138            .next_video_sample()
139            .context("demuxing next video sample in decode pump")?
140        {
141            Some(sample) => {
142                decoder
143                    .push_sample(&sample.data)
144                    .context("pushing sample to decode pump decoder")?;
145                while let Some(frame) =
146                    decoder.decode_next().context("decoding frame in decode pump")?
147                {
148                    match handle_frame(clip, cfg, frame, senders, rt, &mut src_idx, total)? {
149                        FrameAction::Continue => {}
150                        FrameAction::ClipDone => return Ok(Flow::Continue),
151                        FrameAction::StopAll => return Ok(Flow::AllReceiversClosed),
152                    }
153                }
154            }
155            None => {
156                decoder.finish().context("decoder finish in decode pump")?;
157                while let Some(frame) = decoder
158                    .decode_next()
159                    .context("decoding frame after finish in decode pump")?
160                {
161                    match handle_frame(clip, cfg, frame, senders, rt, &mut src_idx, total)? {
162                        FrameAction::Continue => {}
163                        FrameAction::ClipDone => return Ok(Flow::Continue),
164                        FrameAction::StopAll => return Ok(Flow::AllReceiversClosed),
165                    }
166                }
167                break;
168            }
169        }
170    }
171    Ok(Flow::Continue)
172}
173
174enum FrameAction {
175    Continue,
176    ClipDone,
177    StopAll,
178}
179
180/// Apply the clip's trim range to one decoded frame: drop frames before the
181/// in-point, signal `ClipDone` at the out-point, otherwise normalize + fan out.
182fn handle_frame(
183    clip: &ClipSource,
184    cfg: &DecodePumpConfig,
185    frame: VideoFrame,
186    senders: &[tokio::sync::mpsc::Sender<VideoFrame>],
187    rt: &tokio::runtime::Handle,
188    src_idx: &mut u64,
189    total: &mut u64,
190) -> Result<FrameAction> {
191    if clip.end_frame.is_some_and(|end| *src_idx >= end) {
192        return Ok(FrameAction::ClipDone); // reached the out-point
193    }
194    if *src_idx >= clip.start_frame {
195        let normalized = normalize_frame(cfg, frame)?;
196        if !fan_out(senders, normalized, rt)? {
197            return Ok(FrameAction::StopAll);
198        }
199        *total += 1;
200    }
201    *src_idx += 1;
202    Ok(FrameAction::Continue)
203}
204
205/// Rung-agnostic per-frame work: 4:4:4 → 4:2:0 downsample (if needed) then,
206/// when the spec's color policy asks for it (`tonemap_to_sdr`), an HDR-aware
207/// colorspace convert (tonemap PQ/HLG → SDR BT.709, identity for SDR). When the
208/// policy is passthrough/HDR, the downsampled source is forwarded unchanged.
209/// Per-rung scaling is NOT done here.
210fn normalize_frame(cfg: &DecodePumpConfig, frame: VideoFrame) -> Result<VideoFrame> {
211    let downsampled = if cfg.needs_downsample {
212        colorspace::downsample_444_to_420_frame(&frame)
213            .context("shared decode pump 4:4:4 → 4:2:0 downsample")?
214    } else {
215        frame
216    };
217    let normalized = if !cfg.tonemap_to_sdr {
218        // Passthrough / HDR output: preserve the source color + bit depth.
219        downsampled
220    } else {
221        colorspace::convert_to_sdr_bt709(&downsampled, &cfg.source_color_metadata)
222            .context("shared decode pump colorspace convert (HDR-aware)")?
223    };
224    // Video filters (crop/pad/flip/rotate/grayscale/overlay/colour) run on the
225    // normalized 4:2:0 frame, before the per-rung scalers see it.
226    if cfg.filters.is_empty() {
227        Ok(normalized)
228    } else {
229        cfg.filters.apply(normalized).context("shared decode pump video filters")
230    }
231}
232
233/// Number of frames timed per candidate when benchmarking decoders. Chosen so
234/// the measurement amortises driver init yet stays well under a second per
235/// candidate even on a modest GPU.
236pub const DECODE_BENCH_FRAMES: usize = 120;
237
238/// Benchmark each candidate GPU by decoding a short prefix of `input` on it and
239/// return the fastest `gpu_index` (what `--decode-with-fastest` pins the pump
240/// to). Construction + first-frame latency is excluded — the clock starts after
241/// a small warmup — so the number reflects steady-state decode throughput, not
242/// driver init. Candidates that fail to construct or decode are skipped;
243/// returns `None` if no candidate produced frames or fewer than two candidates
244/// were given (nothing to choose).
245pub fn fastest_decode_gpu(
246    codec_name: &str,
247    info: &codec::frame::StreamInfo,
248    input: &Bytes,
249    candidates: &[u32],
250    measure_frames: usize,
251) -> Option<u32> {
252    if candidates.len() < 2 {
253        return candidates.first().copied();
254    }
255    let mut best: Option<(u32, f64)> = None;
256    for &gpu in candidates {
257        match bench_decode_gpu(codec_name, info, input, gpu, measure_frames) {
258            Ok(Some(fps)) => {
259                tracing::info!(
260                    gpu_index = gpu,
261                    fps = format!("{fps:.1}"),
262                    "decode-with-fastest: benchmarked candidate"
263                );
264                if best.is_none_or(|(_, b)| fps > b) {
265                    best = Some((gpu, fps));
266                }
267            }
268            Ok(None) => {
269                tracing::warn!(gpu_index = gpu, "decode-with-fastest: no frames; skipping candidate")
270            }
271            Err(e) => tracing::warn!(
272                gpu_index = gpu,
273                error = %e,
274                "decode-with-fastest: bench failed; skipping candidate"
275            ),
276        }
277    }
278    if let Some((gpu, fps)) = best {
279        tracing::info!(
280            gpu_index = gpu,
281            fps = format!("{fps:.1}"),
282            "decode-with-fastest: selected fastest decode GPU"
283        );
284    }
285    best.map(|(g, _)| g)
286}
287
288/// Decode up to `measure_frames` frames (after an 8-frame warmup) from `input`
289/// on `gpu`, returning the measured fps — or `None` if it produced no frames.
290fn bench_decode_gpu(
291    codec_name: &str,
292    info: &codec::frame::StreamInfo,
293    input: &Bytes,
294    gpu: u32,
295    measure_frames: usize,
296) -> Result<Option<f64>> {
297    const WARMUP: usize = 8;
298    let target = WARMUP + measure_frames;
299    let mut demuxer = streaming::demux_streaming(input).context("demux for decode bench")?;
300    let mut decoder = decode::create_decoder_on(codec_name, info.clone(), Some(gpu))
301        .context("create decoder for bench")?;
302    let mut decoded = 0usize;
303    let mut clock: Option<Instant> = None;
304    'outer: loop {
305        match demuxer.next_video_sample().context("bench next sample")? {
306            Some(s) => {
307                decoder.push_sample(&s.data).context("bench push")?;
308                while decoder.decode_next().context("bench decode")?.is_some() {
309                    decoded += 1;
310                    if decoded == WARMUP {
311                        clock = Some(Instant::now());
312                    }
313                    if decoded >= target {
314                        break 'outer;
315                    }
316                }
317            }
318            None => {
319                decoder.finish().context("bench finish")?;
320                while decoder.decode_next().context("bench drain")?.is_some() {
321                    decoded += 1;
322                    if decoded == WARMUP {
323                        clock = Some(Instant::now());
324                    }
325                    if decoded >= target {
326                        break 'outer;
327                    }
328                }
329                break;
330            }
331        }
332    }
333    let measured = decoded.saturating_sub(WARMUP);
334    Ok(match clock {
335        Some(t) if measured > 0 => {
336            let secs = t.elapsed().as_secs_f64();
337            (secs > 0.0).then_some(measured as f64 / secs)
338        }
339        // Tiny clip (< WARMUP+1 frames): every candidate decodes the same few
340        // frames, so return the count — equal across candidates, first wins.
341        _ => (decoded > 0).then_some(decoded as f64),
342    })
343}
344
345/// Fan one frame out to every sender. Cloning `VideoFrame` is cheap (inner
346/// `Bytes` is `Arc`-backed). Returns `false` only if EVERY sender is closed.
347fn fan_out(
348    senders: &[tokio::sync::mpsc::Sender<VideoFrame>],
349    frame: VideoFrame,
350    rt: &tokio::runtime::Handle,
351) -> Result<bool> {
352    let mut any_alive = false;
353    for (idx, sender) in senders.iter().enumerate() {
354        let frame_clone = frame.clone();
355        let sender = sender.clone();
356        let accepted = rt.block_on(async move { sender.send(frame_clone).await });
357        match accepted {
358            Ok(()) => any_alive = true,
359            Err(_) => {
360                tracing::warn!(rung_idx = idx, "shared decode pump: rung dropped its receiver");
361            }
362        }
363    }
364    Ok(any_alive)
365}