rivet/spec.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};
21
22use codec::encode::tuning::{QualityTarget, SpeedTier};
23use codec::encode::{AUTO_FROM_TARGET, EncoderConfig};
24use codec::frame::{ColorMetadata, PixelFormat, TransferFn};
25
26pub use codec::encode::tuning::{QualityTarget as PerceptualTarget, SpeedTier as Speed};
27
28/// The low-level codec identity used by the encoder + muxer, re-exported from
29/// [`codec::frame::VideoCodec`]. Most callers pick the codec via
30/// [`VideoCodecPolicy`] (the spec-level dimension) and never touch this directly;
31/// `VideoCodecPolicy::codec` resolves to it.
32pub use codec::frame::VideoCodec;
33
34/// Output **video** codec policy — the video analogue of [`AudioCodecPolicy`].
35/// Selects which codec the encoder produces:
36/// - `Av1` *(default)* — royalty-clean (AV1 + Opus in MP4 = zero royalty exposure).
37/// - `H264` / `H265` — for legacy-player compatibility; they carry the
38/// patent-licensing obligations AV1 was chosen to avoid.
39///
40/// All three work for single-file MP4 **and** CMAF/HLS. Resolve to the
41/// encoder/muxer's [`VideoCodec`] with [`VideoCodecPolicy::codec`].
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
43pub enum VideoCodecPolicy {
44 #[default]
45 Av1,
46 H264,
47 H265,
48}
49
50impl VideoCodecPolicy {
51 /// Resolve to the low-level [`VideoCodec`] the encoder + muxer consume.
52 pub fn codec(self) -> VideoCodec {
53 match self {
54 VideoCodecPolicy::Av1 => VideoCodec::Av1,
55 VideoCodecPolicy::H264 => VideoCodec::H264,
56 VideoCodecPolicy::H265 => VideoCodec::H265,
57 }
58 }
59}
60
61/// Output **audio** codec policy — how the source audio track is handled.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
63pub enum AudioCodecPolicy {
64 /// Passthrough AAC / Opus / AC-3 / E-AC-3 verbatim; transcode MP3 /
65 /// Vorbis to Opus; drop anything else.
66 #[default]
67 Auto,
68 /// Keep/produce Opus: passthrough Opus, transcode everything else to Opus.
69 ForceOpus,
70 /// Drop audio entirely (video-only output).
71 Drop,
72}
73
74/// Deprecated alias for [`AudioCodecPolicy`] (renamed for symmetry with
75/// [`VideoCodecPolicy`]).
76#[deprecated(since = "0.1.5", note = "renamed to AudioCodecPolicy")]
77pub type AudioPolicy = AudioCodecPolicy;
78
79/// Output container.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
81pub enum Container {
82 /// Plain MP4 (ISO-BMFF), one self-contained file.
83 #[default]
84 Mp4,
85 /// Fragmented MP4 (CMAF) — `moof`+`mdat` segments, for HLS/DASH.
86 Cmaf,
87}
88
89/// Muxer — how the container bytes are assembled.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Muxer {
92 /// `Av1Mp4Muxer` — a single faststart MP4 with interleaved A/V.
93 #[default]
94 Mp4File,
95 /// `CmafVideoMuxer` + `CmafAudioMuxer` + HLS playlists.
96 CmafHls,
97}
98
99/// The high-level shape of the output.
100#[derive(Debug, Clone, PartialEq)]
101pub enum OutputMode {
102 /// One self-contained file per rung.
103 SingleFile,
104 /// Segmented CMAF + HLS: a media playlist per rung, a shared audio
105 /// rendition, and a master playlist. `segment_seconds` is the target
106 /// segment length (segments still break on keyframes).
107 Hls { segment_seconds: f32 },
108}
109
110impl Default for OutputMode {
111 fn default() -> Self {
112 OutputMode::SingleFile
113 }
114}
115
116/// Encoder quality knobs for a rung.
117#[derive(Debug, Clone)]
118pub struct Quality {
119 /// Constant rate factor in the encoder-native scale (rav1e/NVENC 0..=255).
120 /// `None` derives the quantizer from [`Quality::target`].
121 pub crf: Option<u8>,
122 /// Encoder-native speed preset. `None` derives it from [`Quality::tier`].
123 pub speed_preset: Option<u8>,
124 /// Perceptual quality target (used when `crf` is `None`).
125 pub target: QualityTarget,
126 /// Speed/efficiency tier (used when `speed_preset` is `None`).
127 pub tier: SpeedTier,
128 /// GOP length in frames. `None` → `2 × frame_rate` (a 2-second GOP).
129 pub keyframe_interval: Option<u32>,
130}
131
132impl Default for Quality {
133 fn default() -> Self {
134 Self {
135 crf: None,
136 speed_preset: None,
137 target: QualityTarget::Standard,
138 tier: SpeedTier::Standard,
139 keyframe_interval: None,
140 }
141 }
142}
143
144impl Quality {
145 /// A constant-rate-factor quality.
146 pub fn crf(crf: u8) -> Self {
147 Self {
148 crf: Some(crf),
149 ..Default::default()
150 }
151 }
152
153 /// A perceptual-target quality.
154 pub fn target(target: QualityTarget) -> Self {
155 Self {
156 target,
157 ..Default::default()
158 }
159 }
160
161 /// Apply these knobs onto an [`EncoderConfig`] for a given frame rate.
162 pub(crate) fn apply(&self, cfg: &mut EncoderConfig, frame_rate: f64) {
163 cfg.target = self.target;
164 cfg.tier = self.tier;
165 cfg.quality = self.crf.unwrap_or(AUTO_FROM_TARGET);
166 cfg.speed_preset = self.speed_preset.unwrap_or(AUTO_FROM_TARGET);
167 cfg.keyframe_interval = self
168 .keyframe_interval
169 .unwrap_or_else(|| (frame_rate * 2.0).round().max(1.0) as u32);
170 }
171}
172
173/// One rendition of the output ladder.
174#[derive(Debug, Clone)]
175pub struct Rung {
176 /// Target width in pixels (even).
177 pub width: u32,
178 /// Target height in pixels (even).
179 pub height: u32,
180 /// Human label, e.g. `"720p"` (short side). Auto-derived by [`Rung::new`].
181 pub label: String,
182 /// Per-rung encoder quality.
183 pub quality: Quality,
184}
185
186impl Rung {
187 /// A rung at `width × height` with default quality and an auto label
188 /// (`"<short-side>p"`).
189 pub fn new(width: u32, height: u32) -> Self {
190 Self {
191 width,
192 height,
193 label: format!("{}p", width.min(height)),
194 quality: Quality::default(),
195 }
196 }
197
198 /// Override the per-rung quality.
199 pub fn with_quality(mut self, quality: Quality) -> Self {
200 self.quality = quality;
201 self
202 }
203
204 /// Override the label.
205 pub fn with_label(mut self, label: impl Into<String>) -> Self {
206 self.label = label.into();
207 self
208 }
209
210 /// Short side (the "p" number).
211 pub fn short_side(&self) -> u32 {
212 self.width.min(self.height)
213 }
214}
215
216/// Full output specification for a transcode job.
217#[derive(Debug, Clone)]
218pub struct OutputSpec {
219 /// Output shape.
220 pub mode: OutputMode,
221 /// Output video codec policy (`Av1` default, or `H264` / `H265`).
222 pub video_codec: VideoCodecPolicy,
223 /// Audio handling.
224 pub audio: AudioCodecPolicy,
225 /// Container format.
226 pub container: Container,
227 /// Muxer.
228 pub muxer: Muxer,
229 /// The ladder. Order is preserved; the first rung is treated as the
230 /// "primary" for single-file callers that only want one output.
231 pub rungs: Vec<Rung>,
232 /// Cap the output frame rate (the encoder's signalled fps is clamped to
233 /// this; the source cadence is otherwise preserved). `None` = source fps.
234 pub max_frame_rate: Option<f64>,
235 /// Pin hardware encode/decode to this GPU index on multi-GPU hosts.
236 /// Kept in sync with `encode_policy` (`SingleGpu(idx)` ⇒ `gpu_index = idx`).
237 pub gpu_index: Option<u32>,
238 /// How to spread encode work across GPUs. See [`EncodePolicy`].
239 pub encode_policy: EncodePolicy,
240 /// Decode-pump GPU override. `None` (default) pins the decode pump to a GPU
241 /// consistent with `encode_policy` (the first device of the selected
242 /// family/set, round-robin for per-rung pumps). `Some(i)` forces decode
243 /// onto GPU `i` — e.g. decode on an iGPU while the dGPUs encode.
244 pub decode_gpu: Option<u32>,
245 /// Output color / tonemap policy. See [`ColorPolicy`].
246 pub color: ColorPolicy,
247 /// Output bit depth. See [`BitDepth`].
248 pub bit_depth: BitDepth,
249 /// How the multi-GPU **single-file** path keeps quality consistent across
250 /// the chunk seams it stitches. See [`ChunkSeamMode`].
251 pub chunk_seam_mode: ChunkSeamMode,
252 /// Video filters applied per-frame **before** per-rung scaling (crop, pad,
253 /// flip, rotate, grayscale). Empty = none. See [`codec::filter`].
254 pub filters: Vec<codec::filter::VideoFilter>,
255}
256
257/// Selects how a job's encode work is distributed across the host's GPUs.
258///
259/// Applies to both the single-file and HLS paths: `AllGpus` runs the multi-GPU
260/// engine (decode once, chunk each rung across every GPU, stitch); `SingleGpu`
261/// constrains the GPU pool to one device and (for single-file) takes the serial
262/// encode path with no chunk overhead.
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
264pub enum EncodePolicy {
265 /// Use **all** available GPUs (the multi-GPU lease-pool engine). For
266 /// single-file this chunk-encodes each rung across the GPUs and stitches
267 /// the packets; it falls back to single-GPU serial encode when only one
268 /// GPU is present or the frame count is unknown. This is the default.
269 #[default]
270 AllGpus,
271 /// Use a **single** GPU. `None` picks the first available GPU; `Some(i)`
272 /// pins to GPU index `i`. Single-file uses the serial encode path.
273 SingleGpu(Option<u32>),
274 /// Use every GPU of one **vendor family** (and only that family) — e.g.
275 /// `Family(GpuFamily::Nvidia)` on a host with an NVIDIA discrete + an
276 /// integrated AMD/Intel GPU uses just the NVIDIA cards. With more than one
277 /// device in the family, single-file chunks across them like `AllGpus`.
278 Family(GpuFamily),
279}
280
281/// A GPU vendor family, for constraining encode to one vendor's devices.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum GpuFamily {
284 Nvidia,
285 Amd,
286 Intel,
287}
288
289/// How the multi-GPU **single-file** path keeps quality consistent across the
290/// chunk seams it stitches into one continuous video.
291///
292/// Only relevant when more than one GPU encodes a single file (the `AllGpus` /
293/// `Family` policies on a multi-GPU host); single-GPU hosts, `SingleGpu`, and
294/// HLS (whose segments are independent by design) are unaffected. AMD (AMF) and
295/// Intel (QSV) chunks are already constant-QP, so their seams are quality-flat
296/// — this chiefly governs **NVENC**, which otherwise runs VBR per chunk and can
297/// leave a mild quality step at the ~2 s boundaries.
298#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
299pub enum ChunkSeamMode {
300 /// Default. Chunk across GPUs for throughput; each chunk uses its encoder's
301 /// normal rate control (VBR on NVENC). Fastest; NVENC may show mild quality
302 /// steps at the seams on complex content.
303 #[default]
304 Parallel,
305 /// Chunk across GPUs but force **constant-QP** so the seams are
306 /// quality-flat, keeping the multi-GPU speedup. The QP is derived from the
307 /// `QualityTarget` (via the per-encoder tuning CQ), so quality still tracks
308 /// the target — the hand-rolled NVENC sets a real const-QP rather than a
309 /// preset default. AMD/QSV are unchanged (already constant-QP).
310 ParallelConstQp,
311 /// Encode the whole file with **one encoder** — seam-free and
312 /// `QualityTarget`-accurate, at the cost of the multi-GPU single-file
313 /// speedup. (Like `SingleGpu`, but leaves multi-GPU in place for HLS jobs.)
314 Serial,
315}
316
317/// Output **color** policy — the gamut (which colors are representable) and the
318/// transfer curve (SDR vs HDR), plus whether to tonemap an HDR source down. This
319/// is the *color* half of the decision; bit depth is the separate [`BitDepth`]
320/// half (though the HDR variants here imply 10-bit on their own).
321///
322/// The decode pump never tonemaps on its own — this policy decides.
323///
324/// Glossary (the jargon these variants use):
325/// - **BT.709** — the standard HD / SDR color gamut. What the vast majority of
326/// video uses; "SDR" output means BT.709.
327/// - **BT.2020** — the *wide* gamut used by HDR: more saturated, deeper colors.
328/// - **PQ** (SMPTE ST 2084) — the HDR10 transfer curve (absolute brightness, up
329/// to 10,000 nits).
330/// - **HLG** (ARIB STD-B67) — the broadcast-friendly HDR transfer curve
331/// (relative brightness; degrades gracefully on SDR screens).
332/// - **tonemap** — squeeze an HDR signal's brightness/gamut down into SDR so it
333/// looks right on ordinary (BT.709, 8-bit) screens.
334#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
335pub enum ColorPolicy {
336 /// **SDR out.** Tonemap HDR (PQ / HLG) sources down to 8-bit **BT.709** SDR;
337 /// SDR sources pass through unchanged. The default — maximally web-compatible.
338 /// (Convenience builder: [`OutputSpec::web_sdr`].)
339 #[default]
340 TonemapToSdr,
341 /// **Verbatim.** Keep the source's gamut, transfer, and bit depth as-is — no
342 /// tonemap, no re-signaling. An HDR source stays HDR (needs a 10-bit
343 /// encoder); an SDR source stays SDR. (Builder: [`OutputSpec::passthrough`].)
344 Passthrough,
345 /// **HDR10 out.** Force **BT.2020** gamut + **PQ** transfer, 10-bit. Sets
346 /// 10-bit on its own, so you do *not* also need [`BitDepth::TenBit`].
347 /// (Builder: [`OutputSpec::hdr10`].)
348 Hdr10,
349 /// **HLG out.** Force **BT.2020** gamut + **HLG** transfer, 10-bit. Implies
350 /// 10-bit. (Builder: [`OutputSpec::hlg`].)
351 Hlg,
352}
353
354impl ColorPolicy {
355 /// Whether the decode pump tonemaps HDR→SDR under this policy.
356 pub fn tonemaps(self) -> bool {
357 matches!(self, ColorPolicy::TonemapToSdr)
358 }
359
360 /// Whether this policy signals HDR (PQ/HLG) in the output bitstream.
361 pub fn is_hdr(self) -> bool {
362 matches!(self, ColorPolicy::Hdr10 | ColorPolicy::Hlg)
363 }
364}
365
366/// Output **bit depth** — bits per sample. The on-disk pixel format is *derived*
367/// from this (the encoder is always AV1 4:2:0, the web-safe chroma subsampling):
368/// 8-bit → **`yuv420p`**, 10-bit → **`yuv420p10le`** (`le` = little-endian 16-bit
369/// words holding 10 valid bits). Bit depth is one axis; gamut + SDR/HDR transfer
370/// is the orthogonal [`ColorPolicy`] axis.
371///
372/// You rarely set this by hand: `Auto` derives it from the color policy.
373#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
374pub enum BitDepth {
375 /// Derive depth from the [`ColorPolicy`]: 8-bit for an SDR tonemap, 10-bit
376 /// for HDR (`Hdr10` / `Hlg`), the source's own depth for `Passthrough`. The
377 /// default — the right choice almost always.
378 #[default]
379 Auto,
380 /// Force **8-bit** 4:2:0 (`yuv420p`) — universal web compatibility.
381 EightBit,
382 /// Force **10-bit** 4:2:0 (`yuv420p10le`) — higher precision (banding-free
383 /// gradients), and required by the HDR policies. Needs a 10-bit-capable
384 /// encoder: NVENC (`nvidia`), AMF (`amd`), QSV (`qsv`), or `ffmpeg`.
385 TenBit,
386}
387
388impl Default for OutputSpec {
389 fn default() -> Self {
390 Self {
391 mode: OutputMode::SingleFile,
392 video_codec: VideoCodecPolicy::Av1,
393 audio: AudioCodecPolicy::Auto,
394 container: Container::Mp4,
395 muxer: Muxer::Mp4File,
396 rungs: Vec::new(),
397 max_frame_rate: None,
398 gpu_index: None,
399 encode_policy: EncodePolicy::default(),
400 decode_gpu: None,
401 color: ColorPolicy::default(),
402 bit_depth: BitDepth::default(),
403 chunk_seam_mode: ChunkSeamMode::default(),
404 filters: Vec::new(),
405 }
406 }
407}
408
409impl OutputSpec {
410 /// One self-contained MP4 per rung (AV1 + Opus/passthrough audio).
411 pub fn single_file(rungs: Vec<Rung>) -> Self {
412 Self {
413 mode: OutputMode::SingleFile,
414 container: Container::Mp4,
415 muxer: Muxer::Mp4File,
416 rungs,
417 ..Default::default()
418 }
419 }
420
421 /// A segmented CMAF + HLS package with the given rungs and segment length.
422 pub fn hls(rungs: Vec<Rung>, segment_seconds: f32) -> Self {
423 Self {
424 mode: OutputMode::Hls { segment_seconds },
425 container: Container::Cmaf,
426 muxer: Muxer::CmafHls,
427 rungs,
428 ..Default::default()
429 }
430 }
431
432 /// Set the audio policy.
433 pub fn with_audio(mut self, audio: AudioCodecPolicy) -> Self {
434 self.audio = audio;
435 self
436 }
437
438 /// Cap output frame rate.
439 pub fn with_max_frame_rate(mut self, fps: f64) -> Self {
440 self.max_frame_rate = Some(fps);
441 self
442 }
443
444 /// Pin to a GPU index. Implies `EncodePolicy::SingleGpu(Some(idx))`.
445 pub fn with_gpu_index(mut self, idx: u32) -> Self {
446 self.gpu_index = Some(idx);
447 self.encode_policy = EncodePolicy::SingleGpu(Some(idx));
448 self
449 }
450
451 /// Select the GPU encode policy: a single (optionally pinned) GPU, or all
452 /// GPUs (the multi-GPU engine).
453 ///
454 /// ```no_run
455 /// # use rivet::spec::{OutputSpec, EncodePolicy, Rung};
456 /// # let rungs: Vec<Rung> = vec![];
457 /// // chunk-encode across every GPU and stitch:
458 /// let _ = OutputSpec::single_file(rungs.clone()).encode_policy(EncodePolicy::AllGpus);
459 /// // serial encode, pinned to GPU 1:
460 /// let _ = OutputSpec::single_file(rungs).encode_policy(EncodePolicy::SingleGpu(Some(1)));
461 /// ```
462 pub fn encode_policy(mut self, policy: EncodePolicy) -> Self {
463 self.encode_policy = policy;
464 if let EncodePolicy::SingleGpu(idx) = policy {
465 self.gpu_index = idx;
466 }
467 self
468 }
469
470 /// Pin the decode pump to a specific GPU index, independent of the encode
471 /// policy. `None` (the default) follows `encode_policy`. Useful to decode on
472 /// an integrated GPU while discrete GPUs encode, or to keep decode on one
473 /// device while encode chunks across several.
474 pub fn decode_gpu(mut self, idx: Option<u32>) -> Self {
475 self.decode_gpu = idx;
476 self
477 }
478
479 /// Set the output color / tonemap policy (SDR tonemap vs HDR passthrough).
480 pub fn with_color(mut self, color: ColorPolicy) -> Self {
481 self.color = color;
482 self
483 }
484
485 /// Set the output **bit depth** (`Auto` / `EightBit` / `TenBit`). Sets bits
486 /// per sample only — the gamut/SDR-HDR choice is [`Self::with_color`]. For
487 /// HDR you usually don't need this (the HDR [`ColorPolicy`] implies 10-bit).
488 pub fn with_bit_depth(mut self, depth: BitDepth) -> Self {
489 self.bit_depth = depth;
490 self
491 }
492
493 // ── Color presets ──────────────────────────────────────────────
494 // One-call intent shortcuts that bundle the color policy (and the bit depth
495 // it implies). Equivalent to the `with_color` / `with_bit_depth` pairs in the
496 // comments, but say what you mean. The low-level builders stay available.
497
498 /// **Web-safe SDR** (the default): BT.709 8-bit, tonemapping any HDR source
499 /// down. Plays everywhere. Same as `.with_color(TonemapToSdr)
500 /// .with_bit_depth(EightBit)`.
501 pub fn web_sdr(self) -> Self {
502 self.with_color(ColorPolicy::TonemapToSdr)
503 .with_bit_depth(BitDepth::EightBit)
504 }
505
506 /// **HDR10**: BT.2020 wide gamut + PQ transfer, 10-bit, no tonemap. Needs a
507 /// 10-bit HDR encoder (`nvidia` / `amd` / `qsv` / `ffmpeg`). Same as
508 /// `.with_color(Hdr10)` — the policy already implies 10-bit.
509 pub fn hdr10(self) -> Self {
510 self.with_color(ColorPolicy::Hdr10)
511 }
512
513 /// **HLG**: BT.2020 wide gamut + HLG transfer, 10-bit, no tonemap. Same as
514 /// `.with_color(Hlg)`.
515 pub fn hlg(self) -> Self {
516 self.with_color(ColorPolicy::Hlg)
517 }
518
519 /// **Passthrough**: keep the source's gamut, transfer, and bit depth
520 /// verbatim. Same as `.with_color(Passthrough)`.
521 pub fn passthrough(self) -> Self {
522 self.with_color(ColorPolicy::Passthrough)
523 }
524
525 /// Set how the multi-GPU single-file path handles chunk seams
526 /// (`Parallel` fastest / `ParallelConstQp` seam-flat / `Serial` seam-free).
527 pub fn chunk_seam_mode(mut self, mode: ChunkSeamMode) -> Self {
528 self.chunk_seam_mode = mode;
529 self
530 }
531
532 /// Set the per-frame video filter chain (crop / pad / flip / rotate /
533 /// grayscale), applied before per-rung scaling. See [`codec::filter`].
534 pub fn with_filters(mut self, filters: Vec<codec::filter::VideoFilter>) -> Self {
535 self.filters = filters;
536 self
537 }
538
539 /// Set the output video codec ([`VideoCodecPolicy::Av1`] default, or `H264` /
540 /// `H265`). All three work for single-file MP4 and CMAF/HLS.
541 pub fn with_video_codec(mut self, codec: VideoCodecPolicy) -> Self {
542 self.video_codec = codec;
543 self
544 }
545
546 /// Whether the decode pump tonemaps HDR→SDR for this spec (policy-driven —
547 /// the pump never decides on its own).
548 pub fn tonemaps(&self) -> bool {
549 self.color.tonemaps()
550 }
551
552 /// Resolve the encoder's input `(color_metadata, pixel_format)` for a given
553 /// source. The default (`TonemapToSdr` + `Auto`) reproduces the legacy
554 /// source-driven fold: HDR sources collapse to 8-bit SDR; SDR sources keep
555 /// their own bit depth and color. `Hdr10`/`Hlg` force BT.2020 10-bit;
556 /// `Passthrough` keeps the source; `pixel_format` overrides the bit depth.
557 pub fn resolve_output(
558 &self,
559 source_color: ColorMetadata,
560 source_pixel_format: PixelFormat,
561 ) -> (ColorMetadata, PixelFormat) {
562 let source_is_hdr = matches!(
563 source_color.transfer,
564 TransferFn::St2084 | TransferFn::AribStdB67
565 );
566 let (color, mut pix) = match self.color {
567 ColorPolicy::TonemapToSdr => {
568 if source_is_hdr {
569 (ColorMetadata::default(), PixelFormat::Yuv420p)
570 } else {
571 (source_color, source_pixel_format)
572 }
573 }
574 ColorPolicy::Passthrough => (source_color, source_pixel_format),
575 ColorPolicy::Hdr10 => (hdr_metadata(TransferFn::St2084), PixelFormat::Yuv420p10le),
576 ColorPolicy::Hlg => (hdr_metadata(TransferFn::AribStdB67), PixelFormat::Yuv420p10le),
577 };
578 match self.bit_depth {
579 BitDepth::Auto => {}
580 BitDepth::EightBit => pix = PixelFormat::Yuv420p,
581 BitDepth::TenBit => pix = PixelFormat::Yuv420p10le,
582 }
583 (color, pix)
584 }
585
586 /// Reject incoherent specifications.
587 pub fn validate(&self) -> Result<()> {
588 if self.rungs.is_empty() {
589 bail!("OutputSpec has no rungs — at least one rendition is required");
590 }
591 for r in &self.rungs {
592 if r.width == 0 || r.height == 0 {
593 bail!("rung '{}' has a zero dimension ({}x{})", r.label, r.width, r.height);
594 }
595 if r.width % 2 != 0 || r.height % 2 != 0 {
596 bail!(
597 "rung '{}' has an odd dimension ({}x{}); 4:2:0 requires even dims",
598 r.label,
599 r.width,
600 r.height
601 );
602 }
603 }
604 // AV1, H.264, and H.265 are all valid for SingleFile MP4 and for
605 // HLS/CMAF (the CMAF muxer builds av01 / avc3 / hev1 init segments and
606 // the codec invariant handles all three across the multi-GPU path).
607 // Container/muxer/mode coherence.
608 match self.mode {
609 OutputMode::SingleFile => {
610 if self.muxer != Muxer::Mp4File || self.container != Container::Mp4 {
611 bail!("SingleFile mode requires Container::Mp4 + Muxer::Mp4File");
612 }
613 }
614 OutputMode::Hls { segment_seconds } => {
615 if self.muxer != Muxer::CmafHls || self.container != Container::Cmaf {
616 bail!("Hls mode requires Container::Cmaf + Muxer::CmafHls");
617 }
618 if !(segment_seconds > 0.0) {
619 bail!("Hls segment_seconds must be > 0 (got {segment_seconds})");
620 }
621 }
622 }
623 // Output color / bit-depth coherence + what this build can produce.
624 if self.color.is_hdr() && matches!(self.bit_depth, BitDepth::EightBit) {
625 bail!(
626 "color {:?} is HDR and requires 10-bit output, but bit_depth is forced to 8-bit",
627 self.color
628 );
629 }
630 let caps = codec::encode::build_output_caps();
631 let needs_10bit = self.color.is_hdr() || matches!(self.bit_depth, BitDepth::TenBit);
632 if needs_10bit && caps.max_bit_depth < 10 {
633 bail!(
634 "10-bit output requested (color={:?}, bit_depth={:?}) but this build has no \
635 10-bit AV1 encoder — build with `nvidia` (NVENC), `amd` (AMF), or `qsv` (oneVPL \
636 P010) for hardware 10-bit, or `ffmpeg` for software.",
637 self.color,
638 self.bit_depth
639 );
640 }
641 if self.color.is_hdr() && !caps.hdr {
642 bail!(
643 "HDR output ({:?}) requested but this build has no HDR-capable encoder — build \
644 with the `nvidia`, `amd`, `qsv`, or `ffmpeg` feature",
645 self.color
646 );
647 }
648 Ok(())
649 }
650}
651
652/// BT.2020 10-bit HDR color metadata for the given transfer (PQ or HLG).
653fn hdr_metadata(transfer: TransferFn) -> ColorMetadata {
654 ColorMetadata {
655 transfer,
656 matrix_coefficients: 9, // BT.2020 non-constant luminance
657 colour_primaries: 9, // BT.2020
658 full_range: false,
659 ..ColorMetadata::default()
660 }
661}
662
663#[cfg(test)]
664mod tests {
665 use super::*;
666
667 #[test]
668 fn single_file_sets_coherent_fields() {
669 let s = OutputSpec::single_file(vec![Rung::new(1280, 720)]);
670 assert_eq!(s.mode, OutputMode::SingleFile);
671 assert_eq!(s.container, Container::Mp4);
672 assert_eq!(s.muxer, Muxer::Mp4File);
673 assert!(s.validate().is_ok());
674 }
675
676 #[test]
677 fn encode_policy_defaults_to_all_gpus() {
678 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
679 assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
680 assert_eq!(s.gpu_index, None);
681 }
682
683 #[test]
684 fn chunk_seam_mode_defaults_parallel_and_builder_sets_it() {
685 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
686 assert_eq!(s.chunk_seam_mode, ChunkSeamMode::Parallel);
687 let s = s.chunk_seam_mode(ChunkSeamMode::Serial);
688 assert_eq!(s.chunk_seam_mode, ChunkSeamMode::Serial);
689 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
690 .chunk_seam_mode(ChunkSeamMode::ParallelConstQp);
691 assert_eq!(s.chunk_seam_mode, ChunkSeamMode::ParallelConstQp);
692 assert!(s.validate().is_ok());
693 }
694
695 #[test]
696 fn encode_policy_single_gpu_syncs_gpu_index() {
697 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
698 .encode_policy(EncodePolicy::SingleGpu(Some(2)));
699 assert_eq!(s.encode_policy, EncodePolicy::SingleGpu(Some(2)));
700 assert_eq!(s.gpu_index, Some(2));
701 }
702
703 #[test]
704 fn with_gpu_index_implies_single_gpu_policy() {
705 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_gpu_index(1);
706 assert_eq!(s.encode_policy, EncodePolicy::SingleGpu(Some(1)));
707 assert_eq!(s.gpu_index, Some(1));
708 }
709
710 #[test]
711 fn encode_policy_family_does_not_pin_gpu_index() {
712 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
713 .encode_policy(EncodePolicy::Family(GpuFamily::Nvidia));
714 assert_eq!(s.encode_policy, EncodePolicy::Family(GpuFamily::Nvidia));
715 // Family is multi-GPU within a vendor — no single-GPU pin.
716 assert_eq!(s.gpu_index, None);
717 }
718
719 #[test]
720 fn decode_gpu_defaults_to_none_and_is_settable() {
721 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
722 assert_eq!(s.decode_gpu, None);
723 let s = s.decode_gpu(Some(0));
724 assert_eq!(s.decode_gpu, Some(0));
725 // decode_gpu is independent of the encode policy.
726 assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
727 }
728
729 #[test]
730 fn encode_policy_all_gpus_leaves_gpu_index_untouched() {
731 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
732 .with_gpu_index(3)
733 .encode_policy(EncodePolicy::AllGpus);
734 // AllGpus doesn't clear an explicit pin; it just won't single-pin.
735 assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
736 assert_eq!(s.gpu_index, Some(3));
737 }
738
739 #[test]
740 fn hls_sets_coherent_fields() {
741 let s = OutputSpec::hls(vec![Rung::new(1920, 1080), Rung::new(640, 360)], 4.0);
742 assert!(matches!(s.mode, OutputMode::Hls { .. }));
743 assert_eq!(s.container, Container::Cmaf);
744 assert_eq!(s.muxer, Muxer::CmafHls);
745 assert!(s.validate().is_ok());
746 }
747
748 #[test]
749 fn validate_rejects_empty_rungs() {
750 assert!(OutputSpec::single_file(vec![]).validate().is_err());
751 }
752
753 #[test]
754 fn validate_rejects_odd_dimensions() {
755 assert!(OutputSpec::single_file(vec![Rung::new(1281, 720)]).validate().is_err());
756 }
757
758 #[test]
759 fn validate_rejects_incoherent_mode_muxer() {
760 let mut s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
761 s.muxer = Muxer::CmafHls; // mismatched with SingleFile mode
762 assert!(s.validate().is_err());
763 }
764
765 #[test]
766 fn rung_label_uses_short_side() {
767 assert_eq!(Rung::new(1920, 1080).label, "1080p");
768 assert_eq!(Rung::new(1080, 1920).label, "1080p");
769 assert_eq!(Rung::new(640, 360).short_side(), 360);
770 }
771
772 #[test]
773 fn color_and_pixel_format_default_to_sdr_8bit() {
774 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
775 assert_eq!(s.color, ColorPolicy::TonemapToSdr);
776 assert_eq!(s.bit_depth, BitDepth::Auto);
777 assert!(s.tonemaps());
778 assert!(s.validate().is_ok());
779 }
780
781 #[test]
782 fn resolve_output_default_folds_hdr_source_to_sdr_8bit() {
783 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
784 let hdr_src = hdr_metadata(TransferFn::St2084);
785 let (color, pix) = s.resolve_output(hdr_src, PixelFormat::Yuv420p10le);
786 // Default TonemapToSdr collapses an HDR 10-bit source to 8-bit SDR.
787 assert_eq!(color.transfer, TransferFn::Bt709);
788 assert_eq!(pix, PixelFormat::Yuv420p);
789 }
790
791 #[test]
792 fn resolve_output_passthrough_keeps_source() {
793 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_color(ColorPolicy::Passthrough);
794 assert!(!s.tonemaps());
795 let src = hdr_metadata(TransferFn::St2084);
796 let (color, pix) = s.resolve_output(src, PixelFormat::Yuv420p10le);
797 assert_eq!(color.transfer, TransferFn::St2084);
798 assert_eq!(pix, PixelFormat::Yuv420p10le);
799 }
800
801 #[test]
802 fn validate_rejects_hdr_without_10bit_or_ffmpeg() {
803 // HDR10 implies 10-bit; without the `ffmpeg` feature the build is 8-bit,
804 // so validation must reject it on a default build.
805 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_color(ColorPolicy::Hdr10);
806 let caps = codec::encode::build_output_caps();
807 if caps.max_bit_depth < 10 {
808 assert!(s.validate().is_err(), "HDR must be rejected on an 8-bit-only build");
809 } else {
810 assert!(s.validate().is_ok());
811 }
812 }
813
814 #[test]
815 fn validate_rejects_hdr_forced_8bit() {
816 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
817 .with_color(ColorPolicy::Hdr10)
818 .with_bit_depth(BitDepth::EightBit);
819 assert!(s.validate().is_err());
820 }
821
822 #[test]
823 fn quality_crf_applies_to_encoder_config() {
824 let q = Quality::crf(28);
825 let mut cfg = EncoderConfig::default();
826 q.apply(&mut cfg, 30.0);
827 assert_eq!(cfg.quality, 28);
828 assert_eq!(cfg.keyframe_interval, 60); // 2 * 30
829 }
830}