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}