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