Skip to main content

rivet/spec/
mod.rs

1//! Output specification — *how* a job should be transcoded.
2//!
3//! A job is described by an [`OutputSpec`]: the [`OutputMode`] (single file
4//! vs segmented HLS), the [`VideoCodec`] + [`AudioCodecPolicy`], the [`Container`]
5//! + [`Muxer`], and the user-defined ladder of [`Rung`]s (each with its own
6//! [`Quality`]). Nothing about the output is hard-coded — the caller decides
7//! the shape, the codec, the quality, and the renditions.
8//!
9//! ```
10//! use rivet::spec::{OutputSpec, Rung, Quality};
11//!
12//! // A 3-rung HLS ladder with 4-second segments.
13//! let spec = OutputSpec::hls(
14//!     vec![Rung::new(1920, 1080), Rung::new(1280, 720), Rung::new(640, 360)],
15//!     4.0,
16//! );
17//! assert!(spec.validate().is_ok());
18//! ```
19
20use anyhow::{Result, bail};
21use codec::frame::{ColorMetadata, PixelFormat, TransferFn};
22
23pub use codec::encode::tuning::{QualityTarget as PerceptualTarget, SpeedTier as Speed};
24
25/// The low-level codec identity used by the encoder + muxer, re-exported from
26/// [`codec::frame::VideoCodec`]. Most callers pick the codec via
27/// [`VideoCodecPolicy`] (the spec-level dimension) and never touch this directly;
28/// `VideoCodecPolicy::codec` resolves to it.
29pub use codec::frame::VideoCodec;
30
31mod policy;
32mod rung;
33#[cfg(test)]
34mod tests;
35
36pub use policy::*;
37pub use rung::*;
38
39/// Full output specification for a transcode job.
40#[derive(Debug, Clone)]
41pub struct OutputSpec {
42    /// Output shape.
43    pub mode: OutputMode,
44    /// Output video codec policy (`Av1` default, or `H264` / `H265`).
45    pub video_codec: VideoCodecPolicy,
46    /// Audio handling.
47    pub audio: AudioCodecPolicy,
48    /// Container format.
49    pub container: Container,
50    /// Muxer.
51    pub muxer: Muxer,
52    /// The ladder. Order is preserved; the first rung is treated as the
53    /// "primary" for single-file callers that only want one output.
54    pub rungs: Vec<Rung>,
55    /// Cap the output frame rate (the encoder's signalled fps is clamped to
56    /// this; the source cadence is otherwise preserved). `None` = source fps.
57    pub max_frame_rate: Option<f64>,
58    /// Pin hardware encode/decode to this GPU index on multi-GPU hosts.
59    /// Kept in sync with `encode_policy` (`SingleGpu(idx)` ⇒ `gpu_index = idx`).
60    pub gpu_index: Option<u32>,
61    /// How to spread encode work across GPUs. See [`EncodePolicy`].
62    pub encode_policy: EncodePolicy,
63    /// How the decode pump's GPU is chosen. See [`DecodePolicy`]: `Auto` (follow
64    /// the encode policy), `SpecificGpu(i)` (force GPU `i`), or `FastestGpu`
65    /// (benchmark every decode-capable GPU up front and pick the quickest).
66    pub decode_policy: DecodePolicy,
67    /// Output color / tonemap policy. See [`ColorPolicy`].
68    pub color: ColorPolicy,
69    /// Output bit depth. See [`BitDepth`].
70    pub bit_depth: BitDepth,
71    /// How the multi-GPU **single-file** path keeps quality consistent across
72    /// the chunk seams it stitches. See [`ChunkSeamMode`].
73    pub chunk_seam_mode: ChunkSeamMode,
74    /// Video filters applied per-frame **before** per-rung scaling (crop, pad,
75    /// flip, rotate, grayscale). Empty = none. See [`codec::filter`].
76    pub filters: Vec<codec::filter::VideoFilter>,
77    /// Splice **trim in-point**, in seconds from the start of the (single)
78    /// input. `None` starts at the beginning. Frames before this point are
79    /// decoded-and-dropped; the output timeline is re-based to zero. For
80    /// multi-clip concatenation use [`run_splice_job`](crate::run_splice_job)
81    /// with a per-clip range instead. Trimmed jobs take the serial encode path.
82    pub trim_start: Option<f64>,
83    /// Splice **trim out-point**, in seconds. `None` keeps the clip to its end.
84    /// The kept range is `[trim_start, trim_end)`.
85    pub trim_end: Option<f64>,
86}
87
88impl Default for OutputSpec {
89    fn default() -> Self {
90        Self {
91            mode: OutputMode::SingleFile,
92            video_codec: VideoCodecPolicy::Av1,
93            audio: AudioCodecPolicy::Auto,
94            container: Container::Mp4,
95            muxer: Muxer::Mp4File,
96            rungs: Vec::new(),
97            max_frame_rate: None,
98            gpu_index: None,
99            encode_policy: EncodePolicy::default(),
100            decode_policy: DecodePolicy::Auto,
101            color: ColorPolicy::default(),
102            bit_depth: BitDepth::default(),
103            chunk_seam_mode: ChunkSeamMode::default(),
104            filters: Vec::new(),
105            trim_start: None,
106            trim_end: None,
107        }
108    }
109}
110
111impl OutputSpec {
112    /// One self-contained MP4 per rung (AV1 + Opus/passthrough audio).
113    pub fn single_file(rungs: Vec<Rung>) -> Self {
114        Self {
115            mode: OutputMode::SingleFile,
116            container: Container::Mp4,
117            muxer: Muxer::Mp4File,
118            rungs,
119            ..Default::default()
120        }
121    }
122
123    /// A segmented CMAF + HLS package with the given rungs and segment length.
124    pub fn hls(rungs: Vec<Rung>, segment_seconds: f32) -> Self {
125        Self {
126            mode: OutputMode::Hls { segment_seconds },
127            container: Container::Cmaf,
128            muxer: Muxer::CmafHls,
129            rungs,
130            ..Default::default()
131        }
132    }
133
134    /// Set the audio policy.
135    pub fn with_audio(mut self, audio: AudioCodecPolicy) -> Self {
136        self.audio = audio;
137        self
138    }
139
140    /// Cap output frame rate.
141    pub fn with_max_frame_rate(mut self, fps: f64) -> Self {
142        self.max_frame_rate = Some(fps);
143        self
144    }
145
146    /// Pin to a GPU index. Implies `EncodePolicy::SingleGpu(Some(idx))`.
147    pub fn with_gpu_index(mut self, idx: u32) -> Self {
148        self.gpu_index = Some(idx);
149        self.encode_policy = EncodePolicy::SingleGpu(Some(idx));
150        self
151    }
152
153    /// Select the GPU encode policy: a single (optionally pinned) GPU, or all
154    /// GPUs (the multi-GPU engine).
155    ///
156    /// ```no_run
157    /// # use rivet::spec::{OutputSpec, EncodePolicy, Rung};
158    /// # let rungs: Vec<Rung> = vec![];
159    /// // chunk-encode across every GPU and stitch:
160    /// let _ = OutputSpec::single_file(rungs.clone()).encode_policy(EncodePolicy::AllGpus);
161    /// // serial encode, pinned to GPU 1:
162    /// let _ = OutputSpec::single_file(rungs).encode_policy(EncodePolicy::SingleGpu(Some(1)));
163    /// ```
164    pub fn encode_policy(mut self, policy: EncodePolicy) -> Self {
165        self.encode_policy = policy;
166        if let EncodePolicy::SingleGpu(idx) = policy {
167            self.gpu_index = idx;
168        }
169        self
170    }
171
172    /// Set the [`DecodePolicy`] — `Auto` (follow `encode_policy`),
173    /// `SpecificGpu(i)` (decode on an iGPU while dGPUs encode, say), or
174    /// `FastestGpu` (benchmark decoders up front and pick the quickest).
175    pub fn decode_policy(mut self, policy: DecodePolicy) -> Self {
176        self.decode_policy = policy;
177        self
178    }
179
180    /// Set the output color / tonemap policy (SDR tonemap vs HDR passthrough).
181    pub fn with_color(mut self, color: ColorPolicy) -> Self {
182        self.color = color;
183        self
184    }
185
186    /// Set the output **bit depth** (`Auto` / `EightBit` / `TenBit`). Sets bits
187    /// per sample only — the gamut/SDR-HDR choice is [`Self::with_color`]. For
188    /// HDR you usually don't need this (the HDR [`ColorPolicy`] implies 10-bit).
189    pub fn with_bit_depth(mut self, depth: BitDepth) -> Self {
190        self.bit_depth = depth;
191        self
192    }
193
194    // ── Color presets ──────────────────────────────────────────────
195    // One-call intent shortcuts that bundle the color policy (and the bit depth
196    // it implies). Equivalent to the `with_color` / `with_bit_depth` pairs in the
197    // comments, but say what you mean. The low-level builders stay available.
198
199    /// **Web-safe SDR** (the default): BT.709 8-bit, tonemapping any HDR source
200    /// down. Plays everywhere. Same as `.with_color(TonemapToSdr)
201    /// .with_bit_depth(EightBit)`.
202    pub fn web_sdr(self) -> Self {
203        self.with_color(ColorPolicy::TonemapToSdr)
204            .with_bit_depth(BitDepth::EightBit)
205    }
206
207    /// **HDR10**: BT.2020 wide gamut + PQ transfer, 10-bit, no tonemap. Needs a
208    /// 10-bit HDR encoder (`nvidia` / `amd` / `qsv` / `ffmpeg`). Same as
209    /// `.with_color(Hdr10)` — the policy already implies 10-bit.
210    pub fn hdr10(self) -> Self {
211        self.with_color(ColorPolicy::Hdr10)
212    }
213
214    /// **HLG**: BT.2020 wide gamut + HLG transfer, 10-bit, no tonemap. Same as
215    /// `.with_color(Hlg)`.
216    pub fn hlg(self) -> Self {
217        self.with_color(ColorPolicy::Hlg)
218    }
219
220    /// **Passthrough**: keep the source's gamut, transfer, and bit depth
221    /// verbatim. Same as `.with_color(Passthrough)`.
222    pub fn passthrough(self) -> Self {
223        self.with_color(ColorPolicy::Passthrough)
224    }
225
226    /// Set how the multi-GPU single-file path handles chunk seams
227    /// (`Parallel` fastest / `ParallelConstQp` seam-flat / `Serial` seam-free).
228    pub fn chunk_seam_mode(mut self, mode: ChunkSeamMode) -> Self {
229        self.chunk_seam_mode = mode;
230        self
231    }
232
233    /// Set the per-frame video filter chain (crop / pad / flip / rotate /
234    /// grayscale), applied before per-rung scaling. See [`codec::filter`].
235    pub fn with_filters(mut self, filters: Vec<codec::filter::VideoFilter>) -> Self {
236        self.filters = filters;
237        self
238    }
239
240    /// **Trim** the single input to the time range `[start, end)` in seconds
241    /// (either bound `None` = open). The output is re-based to zero. Trimmed
242    /// jobs use the serial encode path. For joining multiple clips, see
243    /// [`run_splice_job`](crate::run_splice_job).
244    pub fn with_trim(mut self, start: Option<f64>, end: Option<f64>) -> Self {
245        self.trim_start = start;
246        self.trim_end = end;
247        self
248    }
249
250    /// Set the output video codec ([`VideoCodecPolicy::Av1`] default, or `H264` /
251    /// `H265`). All three work for single-file MP4 and CMAF/HLS.
252    pub fn with_video_codec(mut self, codec: VideoCodecPolicy) -> Self {
253        self.video_codec = codec;
254        self
255    }
256
257    /// Whether the decode pump tonemaps HDR→SDR for this spec (policy-driven —
258    /// the pump never decides on its own).
259    pub fn tonemaps(&self) -> bool {
260        self.color.tonemaps()
261    }
262
263    /// Resolve the encoder's input `(color_metadata, pixel_format)` for a given
264    /// source. The default (`TonemapToSdr` + `Auto`) reproduces the legacy
265    /// source-driven fold: HDR sources collapse to 8-bit SDR; SDR sources keep
266    /// their own bit depth and color. `Hdr10`/`Hlg` force BT.2020 10-bit;
267    /// `Passthrough` keeps the source; `pixel_format` overrides the bit depth.
268    pub fn resolve_output(
269        &self,
270        source_color: ColorMetadata,
271        source_pixel_format: PixelFormat,
272    ) -> (ColorMetadata, PixelFormat) {
273        let source_is_hdr = matches!(
274            source_color.transfer,
275            TransferFn::St2084 | TransferFn::AribStdB67
276        );
277        let (color, mut pix) = match self.color {
278            ColorPolicy::TonemapToSdr => {
279                if source_is_hdr {
280                    (ColorMetadata::default(), PixelFormat::Yuv420p)
281                } else {
282                    (source_color, source_pixel_format)
283                }
284            }
285            ColorPolicy::Passthrough => (source_color, source_pixel_format),
286            ColorPolicy::Hdr10 => (hdr_metadata(TransferFn::St2084), PixelFormat::Yuv420p10le),
287            ColorPolicy::Hlg => (hdr_metadata(TransferFn::AribStdB67), PixelFormat::Yuv420p10le),
288        };
289        match self.bit_depth {
290            BitDepth::Auto => {}
291            BitDepth::EightBit => pix = PixelFormat::Yuv420p,
292            BitDepth::TenBit => pix = PixelFormat::Yuv420p10le,
293        }
294        (color, pix)
295    }
296
297    /// Reject incoherent specifications.
298    pub fn validate(&self) -> Result<()> {
299        if self.rungs.is_empty() {
300            bail!("OutputSpec has no rungs — at least one rendition is required");
301        }
302        for r in &self.rungs {
303            if r.width == 0 || r.height == 0 {
304                bail!("rung '{}' has a zero dimension ({}x{})", r.label, r.width, r.height);
305            }
306            if r.width % 2 != 0 || r.height % 2 != 0 {
307                bail!(
308                    "rung '{}' has an odd dimension ({}x{}); 4:2:0 requires even dims",
309                    r.label,
310                    r.width,
311                    r.height
312                );
313            }
314        }
315        // AV1, H.264, and H.265 are all valid for SingleFile MP4 and for
316        // HLS/CMAF (the CMAF muxer builds av01 / avc3 / hev1 init segments and
317        // the codec invariant handles all three across the multi-GPU path).
318        // Container/muxer/mode coherence.
319        match self.mode {
320            OutputMode::SingleFile => {
321                if self.muxer != Muxer::Mp4File || self.container != Container::Mp4 {
322                    bail!("SingleFile mode requires Container::Mp4 + Muxer::Mp4File");
323                }
324            }
325            OutputMode::Hls { segment_seconds } => {
326                if self.muxer != Muxer::CmafHls || self.container != Container::Cmaf {
327                    bail!("Hls mode requires Container::Cmaf + Muxer::CmafHls");
328                }
329                if !(segment_seconds > 0.0) {
330                    bail!("Hls segment_seconds must be > 0 (got {segment_seconds})");
331                }
332            }
333        }
334        // Output color / bit-depth coherence + what this build can produce.
335        if self.color.is_hdr() && matches!(self.bit_depth, BitDepth::EightBit) {
336            bail!(
337                "color {:?} is HDR and requires 10-bit output, but bit_depth is forced to 8-bit",
338                self.color
339            );
340        }
341        let caps = codec::encode::build_output_caps();
342        let needs_10bit = self.color.is_hdr() || matches!(self.bit_depth, BitDepth::TenBit);
343        if needs_10bit && caps.max_bit_depth < 10 {
344            bail!(
345                "10-bit output requested (color={:?}, bit_depth={:?}) but this build has no \
346                 10-bit AV1 encoder — build with `nvidia` (NVENC), `amd` (AMF), or `qsv` (oneVPL \
347                 P010) for hardware 10-bit, or `ffmpeg` for software.",
348                self.color,
349                self.bit_depth
350            );
351        }
352        if self.color.is_hdr() && !caps.hdr {
353            bail!(
354                "HDR output ({:?}) requested but this build has no HDR-capable encoder — build \
355                 with the `nvidia`, `amd`, `qsv`, or `ffmpeg` feature",
356                self.color
357            );
358        }
359        Ok(())
360    }
361}
362
363/// BT.2020 10-bit HDR color metadata for the given transfer (PQ or HLG).
364fn hdr_metadata(transfer: TransferFn) -> ColorMetadata {
365    ColorMetadata {
366        transfer,
367        matrix_coefficients: 9, // BT.2020 non-constant luminance
368        colour_primaries: 9,    // BT.2020
369        full_range: false,
370        ..ColorMetadata::default()
371    }
372}