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}
227
228/// Selects how a job's encode work is distributed across the host's GPUs.
229///
230/// Applies to both the single-file and HLS paths: `AllGpus` runs the multi-GPU
231/// engine (decode once, chunk each rung across every GPU, stitch); `SingleGpu`
232/// constrains the GPU pool to one device and (for single-file) takes the serial
233/// encode path with no chunk overhead.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
235pub enum EncodePolicy {
236 /// Use **all** available GPUs (the multi-GPU lease-pool engine). For
237 /// single-file this chunk-encodes each rung across the GPUs and stitches
238 /// the packets; it falls back to single-GPU serial encode when only one
239 /// GPU is present or the frame count is unknown. This is the default.
240 #[default]
241 AllGpus,
242 /// Use a **single** GPU. `None` picks the first available GPU; `Some(i)`
243 /// pins to GPU index `i`. Single-file uses the serial encode path.
244 SingleGpu(Option<u32>),
245 /// Use every GPU of one **vendor family** (and only that family) — e.g.
246 /// `Family(GpuFamily::Nvidia)` on a host with an NVIDIA discrete + an
247 /// integrated AMD/Intel GPU uses just the NVIDIA cards. With more than one
248 /// device in the family, single-file chunks across them like `AllGpus`.
249 Family(GpuFamily),
250}
251
252/// A GPU vendor family, for constraining encode to one vendor's devices.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum GpuFamily {
255 Nvidia,
256 Amd,
257 Intel,
258}
259
260/// How the multi-GPU **single-file** path keeps quality consistent across the
261/// chunk seams it stitches into one continuous video.
262///
263/// Only relevant when more than one GPU encodes a single file (the `AllGpus` /
264/// `Family` policies on a multi-GPU host); single-GPU hosts, `SingleGpu`, and
265/// HLS (whose segments are independent by design) are unaffected. AMD (AMF) and
266/// Intel (QSV) chunks are already constant-QP, so their seams are quality-flat
267/// — this chiefly governs **NVENC**, which otherwise runs VBR per chunk and can
268/// leave a mild quality step at the ~2 s boundaries.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
270pub enum ChunkSeamMode {
271 /// Default. Chunk across GPUs for throughput; each chunk uses its encoder's
272 /// normal rate control (VBR on NVENC). Fastest; NVENC may show mild quality
273 /// steps at the seams on complex content.
274 #[default]
275 Parallel,
276 /// Chunk across GPUs but force **constant-QP** so the seams are
277 /// quality-flat, keeping the multi-GPU speedup. The QP is derived from the
278 /// `QualityTarget` (via the per-encoder tuning CQ), so quality still tracks
279 /// the target — the hand-rolled NVENC sets a real const-QP rather than a
280 /// preset default. AMD/QSV are unchanged (already constant-QP).
281 ParallelConstQp,
282 /// Encode the whole file with **one encoder** — seam-free and
283 /// `QualityTarget`-accurate, at the cost of the multi-GPU single-file
284 /// speedup. (Like `SingleGpu`, but leaves multi-GPU in place for HLS jobs.)
285 Serial,
286}
287
288/// Output **color** policy — the gamut (which colors are representable) and the
289/// transfer curve (SDR vs HDR), plus whether to tonemap an HDR source down. This
290/// is the *color* half of the decision; bit depth is the separate [`BitDepth`]
291/// half (though the HDR variants here imply 10-bit on their own).
292///
293/// The decode pump never tonemaps on its own — this policy decides.
294///
295/// Glossary (the jargon these variants use):
296/// - **BT.709** — the standard HD / SDR color gamut. What the vast majority of
297/// video uses; "SDR" output means BT.709.
298/// - **BT.2020** — the *wide* gamut used by HDR: more saturated, deeper colors.
299/// - **PQ** (SMPTE ST 2084) — the HDR10 transfer curve (absolute brightness, up
300/// to 10,000 nits).
301/// - **HLG** (ARIB STD-B67) — the broadcast-friendly HDR transfer curve
302/// (relative brightness; degrades gracefully on SDR screens).
303/// - **tonemap** — squeeze an HDR signal's brightness/gamut down into SDR so it
304/// looks right on ordinary (BT.709, 8-bit) screens.
305#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
306pub enum ColorPolicy {
307 /// **SDR out.** Tonemap HDR (PQ / HLG) sources down to 8-bit **BT.709** SDR;
308 /// SDR sources pass through unchanged. The default — maximally web-compatible.
309 /// (Convenience builder: [`OutputSpec::web_sdr`].)
310 #[default]
311 TonemapToSdr,
312 /// **Verbatim.** Keep the source's gamut, transfer, and bit depth as-is — no
313 /// tonemap, no re-signaling. An HDR source stays HDR (needs a 10-bit
314 /// encoder); an SDR source stays SDR. (Builder: [`OutputSpec::passthrough`].)
315 Passthrough,
316 /// **HDR10 out.** Force **BT.2020** gamut + **PQ** transfer, 10-bit. Sets
317 /// 10-bit on its own, so you do *not* also need [`BitDepth::TenBit`].
318 /// (Builder: [`OutputSpec::hdr10`].)
319 Hdr10,
320 /// **HLG out.** Force **BT.2020** gamut + **HLG** transfer, 10-bit. Implies
321 /// 10-bit. (Builder: [`OutputSpec::hlg`].)
322 Hlg,
323}
324
325impl ColorPolicy {
326 /// Whether the decode pump tonemaps HDR→SDR under this policy.
327 pub fn tonemaps(self) -> bool {
328 matches!(self, ColorPolicy::TonemapToSdr)
329 }
330
331 /// Whether this policy signals HDR (PQ/HLG) in the output bitstream.
332 pub fn is_hdr(self) -> bool {
333 matches!(self, ColorPolicy::Hdr10 | ColorPolicy::Hlg)
334 }
335}
336
337/// Output **bit depth** — bits per sample. The on-disk pixel format is *derived*
338/// from this (the encoder is always AV1 4:2:0, the web-safe chroma subsampling):
339/// 8-bit → **`yuv420p`**, 10-bit → **`yuv420p10le`** (`le` = little-endian 16-bit
340/// words holding 10 valid bits). Bit depth is one axis; gamut + SDR/HDR transfer
341/// is the orthogonal [`ColorPolicy`] axis.
342///
343/// You rarely set this by hand: `Auto` derives it from the color policy.
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
345pub enum BitDepth {
346 /// Derive depth from the [`ColorPolicy`]: 8-bit for an SDR tonemap, 10-bit
347 /// for HDR (`Hdr10` / `Hlg`), the source's own depth for `Passthrough`. The
348 /// default — the right choice almost always.
349 #[default]
350 Auto,
351 /// Force **8-bit** 4:2:0 (`yuv420p`) — universal web compatibility.
352 EightBit,
353 /// Force **10-bit** 4:2:0 (`yuv420p10le`) — higher precision (banding-free
354 /// gradients), and required by the HDR policies. Needs a 10-bit-capable
355 /// encoder: NVENC (`nvidia`), AMF (`amd`), QSV (`qsv`), or `ffmpeg`.
356 TenBit,
357}
358
359impl Default for OutputSpec {
360 fn default() -> Self {
361 Self {
362 mode: OutputMode::SingleFile,
363 video_codec: VideoCodec::Av1,
364 audio: AudioPolicy::Auto,
365 container: Container::Mp4,
366 muxer: Muxer::Mp4File,
367 rungs: Vec::new(),
368 max_frame_rate: None,
369 gpu_index: None,
370 encode_policy: EncodePolicy::default(),
371 decode_gpu: None,
372 color: ColorPolicy::default(),
373 bit_depth: BitDepth::default(),
374 chunk_seam_mode: ChunkSeamMode::default(),
375 }
376 }
377}
378
379impl OutputSpec {
380 /// One self-contained MP4 per rung (AV1 + Opus/passthrough audio).
381 pub fn single_file(rungs: Vec<Rung>) -> Self {
382 Self {
383 mode: OutputMode::SingleFile,
384 container: Container::Mp4,
385 muxer: Muxer::Mp4File,
386 rungs,
387 ..Default::default()
388 }
389 }
390
391 /// A segmented CMAF + HLS package with the given rungs and segment length.
392 pub fn hls(rungs: Vec<Rung>, segment_seconds: f32) -> Self {
393 Self {
394 mode: OutputMode::Hls { segment_seconds },
395 container: Container::Cmaf,
396 muxer: Muxer::CmafHls,
397 rungs,
398 ..Default::default()
399 }
400 }
401
402 /// Set the audio policy.
403 pub fn with_audio(mut self, audio: AudioPolicy) -> Self {
404 self.audio = audio;
405 self
406 }
407
408 /// Cap output frame rate.
409 pub fn with_max_frame_rate(mut self, fps: f64) -> Self {
410 self.max_frame_rate = Some(fps);
411 self
412 }
413
414 /// Pin to a GPU index. Implies `EncodePolicy::SingleGpu(Some(idx))`.
415 pub fn with_gpu_index(mut self, idx: u32) -> Self {
416 self.gpu_index = Some(idx);
417 self.encode_policy = EncodePolicy::SingleGpu(Some(idx));
418 self
419 }
420
421 /// Select the GPU encode policy: a single (optionally pinned) GPU, or all
422 /// GPUs (the multi-GPU engine).
423 ///
424 /// ```no_run
425 /// # use rivet::spec::{OutputSpec, EncodePolicy, Rung};
426 /// # let rungs: Vec<Rung> = vec![];
427 /// // chunk-encode across every GPU and stitch:
428 /// let _ = OutputSpec::single_file(rungs.clone()).encode_policy(EncodePolicy::AllGpus);
429 /// // serial encode, pinned to GPU 1:
430 /// let _ = OutputSpec::single_file(rungs).encode_policy(EncodePolicy::SingleGpu(Some(1)));
431 /// ```
432 pub fn encode_policy(mut self, policy: EncodePolicy) -> Self {
433 self.encode_policy = policy;
434 if let EncodePolicy::SingleGpu(idx) = policy {
435 self.gpu_index = idx;
436 }
437 self
438 }
439
440 /// Pin the decode pump to a specific GPU index, independent of the encode
441 /// policy. `None` (the default) follows `encode_policy`. Useful to decode on
442 /// an integrated GPU while discrete GPUs encode, or to keep decode on one
443 /// device while encode chunks across several.
444 pub fn decode_gpu(mut self, idx: Option<u32>) -> Self {
445 self.decode_gpu = idx;
446 self
447 }
448
449 /// Set the output color / tonemap policy (SDR tonemap vs HDR passthrough).
450 pub fn with_color(mut self, color: ColorPolicy) -> Self {
451 self.color = color;
452 self
453 }
454
455 /// Set the output **bit depth** (`Auto` / `EightBit` / `TenBit`). Sets bits
456 /// per sample only — the gamut/SDR-HDR choice is [`Self::with_color`]. For
457 /// HDR you usually don't need this (the HDR [`ColorPolicy`] implies 10-bit).
458 pub fn with_bit_depth(mut self, depth: BitDepth) -> Self {
459 self.bit_depth = depth;
460 self
461 }
462
463 // ── Color presets ──────────────────────────────────────────────
464 // One-call intent shortcuts that bundle the color policy (and the bit depth
465 // it implies). Equivalent to the `with_color` / `with_bit_depth` pairs in the
466 // comments, but say what you mean. The low-level builders stay available.
467
468 /// **Web-safe SDR** (the default): BT.709 8-bit, tonemapping any HDR source
469 /// down. Plays everywhere. Same as `.with_color(TonemapToSdr)
470 /// .with_bit_depth(EightBit)`.
471 pub fn web_sdr(self) -> Self {
472 self.with_color(ColorPolicy::TonemapToSdr)
473 .with_bit_depth(BitDepth::EightBit)
474 }
475
476 /// **HDR10**: BT.2020 wide gamut + PQ transfer, 10-bit, no tonemap. Needs a
477 /// 10-bit HDR encoder (`nvidia` / `amd` / `qsv` / `ffmpeg`). Same as
478 /// `.with_color(Hdr10)` — the policy already implies 10-bit.
479 pub fn hdr10(self) -> Self {
480 self.with_color(ColorPolicy::Hdr10)
481 }
482
483 /// **HLG**: BT.2020 wide gamut + HLG transfer, 10-bit, no tonemap. Same as
484 /// `.with_color(Hlg)`.
485 pub fn hlg(self) -> Self {
486 self.with_color(ColorPolicy::Hlg)
487 }
488
489 /// **Passthrough**: keep the source's gamut, transfer, and bit depth
490 /// verbatim. Same as `.with_color(Passthrough)`.
491 pub fn passthrough(self) -> Self {
492 self.with_color(ColorPolicy::Passthrough)
493 }
494
495 /// Set how the multi-GPU single-file path handles chunk seams
496 /// (`Parallel` fastest / `ParallelConstQp` seam-flat / `Serial` seam-free).
497 pub fn chunk_seam_mode(mut self, mode: ChunkSeamMode) -> Self {
498 self.chunk_seam_mode = mode;
499 self
500 }
501
502 /// Whether the decode pump tonemaps HDR→SDR for this spec (policy-driven —
503 /// the pump never decides on its own).
504 pub fn tonemaps(&self) -> bool {
505 self.color.tonemaps()
506 }
507
508 /// Resolve the encoder's input `(color_metadata, pixel_format)` for a given
509 /// source. The default (`TonemapToSdr` + `Auto`) reproduces the legacy
510 /// source-driven fold: HDR sources collapse to 8-bit SDR; SDR sources keep
511 /// their own bit depth and color. `Hdr10`/`Hlg` force BT.2020 10-bit;
512 /// `Passthrough` keeps the source; `pixel_format` overrides the bit depth.
513 pub fn resolve_output(
514 &self,
515 source_color: ColorMetadata,
516 source_pixel_format: PixelFormat,
517 ) -> (ColorMetadata, PixelFormat) {
518 let source_is_hdr = matches!(
519 source_color.transfer,
520 TransferFn::St2084 | TransferFn::AribStdB67
521 );
522 let (color, mut pix) = match self.color {
523 ColorPolicy::TonemapToSdr => {
524 if source_is_hdr {
525 (ColorMetadata::default(), PixelFormat::Yuv420p)
526 } else {
527 (source_color, source_pixel_format)
528 }
529 }
530 ColorPolicy::Passthrough => (source_color, source_pixel_format),
531 ColorPolicy::Hdr10 => (hdr_metadata(TransferFn::St2084), PixelFormat::Yuv420p10le),
532 ColorPolicy::Hlg => (hdr_metadata(TransferFn::AribStdB67), PixelFormat::Yuv420p10le),
533 };
534 match self.bit_depth {
535 BitDepth::Auto => {}
536 BitDepth::EightBit => pix = PixelFormat::Yuv420p,
537 BitDepth::TenBit => pix = PixelFormat::Yuv420p10le,
538 }
539 (color, pix)
540 }
541
542 /// Reject incoherent specifications.
543 pub fn validate(&self) -> Result<()> {
544 if self.rungs.is_empty() {
545 bail!("OutputSpec has no rungs — at least one rendition is required");
546 }
547 for r in &self.rungs {
548 if r.width == 0 || r.height == 0 {
549 bail!("rung '{}' has a zero dimension ({}x{})", r.label, r.width, r.height);
550 }
551 if r.width % 2 != 0 || r.height % 2 != 0 {
552 bail!(
553 "rung '{}' has an odd dimension ({}x{}); 4:2:0 requires even dims",
554 r.label,
555 r.width,
556 r.height
557 );
558 }
559 }
560 // Container/muxer/mode coherence.
561 match self.mode {
562 OutputMode::SingleFile => {
563 if self.muxer != Muxer::Mp4File || self.container != Container::Mp4 {
564 bail!("SingleFile mode requires Container::Mp4 + Muxer::Mp4File");
565 }
566 }
567 OutputMode::Hls { segment_seconds } => {
568 if self.muxer != Muxer::CmafHls || self.container != Container::Cmaf {
569 bail!("Hls mode requires Container::Cmaf + Muxer::CmafHls");
570 }
571 if !(segment_seconds > 0.0) {
572 bail!("Hls segment_seconds must be > 0 (got {segment_seconds})");
573 }
574 }
575 }
576 // Output color / bit-depth coherence + what this build can produce.
577 if self.color.is_hdr() && matches!(self.bit_depth, BitDepth::EightBit) {
578 bail!(
579 "color {:?} is HDR and requires 10-bit output, but bit_depth is forced to 8-bit",
580 self.color
581 );
582 }
583 let caps = codec::encode::build_output_caps();
584 let needs_10bit = self.color.is_hdr() || matches!(self.bit_depth, BitDepth::TenBit);
585 if needs_10bit && caps.max_bit_depth < 10 {
586 bail!(
587 "10-bit output requested (color={:?}, bit_depth={:?}) but this build has no \
588 10-bit AV1 encoder — build with `nvidia` (NVENC), `amd` (AMF), or `qsv` (oneVPL \
589 P010) for hardware 10-bit, or `ffmpeg` for software.",
590 self.color,
591 self.bit_depth
592 );
593 }
594 if self.color.is_hdr() && !caps.hdr {
595 bail!(
596 "HDR output ({:?}) requested but this build has no HDR-capable encoder — build \
597 with the `nvidia`, `amd`, `qsv`, or `ffmpeg` feature",
598 self.color
599 );
600 }
601 Ok(())
602 }
603}
604
605/// BT.2020 10-bit HDR color metadata for the given transfer (PQ or HLG).
606fn hdr_metadata(transfer: TransferFn) -> ColorMetadata {
607 ColorMetadata {
608 transfer,
609 matrix_coefficients: 9, // BT.2020 non-constant luminance
610 colour_primaries: 9, // BT.2020
611 full_range: false,
612 ..ColorMetadata::default()
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn single_file_sets_coherent_fields() {
622 let s = OutputSpec::single_file(vec![Rung::new(1280, 720)]);
623 assert_eq!(s.mode, OutputMode::SingleFile);
624 assert_eq!(s.container, Container::Mp4);
625 assert_eq!(s.muxer, Muxer::Mp4File);
626 assert!(s.validate().is_ok());
627 }
628
629 #[test]
630 fn encode_policy_defaults_to_all_gpus() {
631 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
632 assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
633 assert_eq!(s.gpu_index, None);
634 }
635
636 #[test]
637 fn chunk_seam_mode_defaults_parallel_and_builder_sets_it() {
638 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
639 assert_eq!(s.chunk_seam_mode, ChunkSeamMode::Parallel);
640 let s = s.chunk_seam_mode(ChunkSeamMode::Serial);
641 assert_eq!(s.chunk_seam_mode, ChunkSeamMode::Serial);
642 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
643 .chunk_seam_mode(ChunkSeamMode::ParallelConstQp);
644 assert_eq!(s.chunk_seam_mode, ChunkSeamMode::ParallelConstQp);
645 assert!(s.validate().is_ok());
646 }
647
648 #[test]
649 fn encode_policy_single_gpu_syncs_gpu_index() {
650 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
651 .encode_policy(EncodePolicy::SingleGpu(Some(2)));
652 assert_eq!(s.encode_policy, EncodePolicy::SingleGpu(Some(2)));
653 assert_eq!(s.gpu_index, Some(2));
654 }
655
656 #[test]
657 fn with_gpu_index_implies_single_gpu_policy() {
658 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_gpu_index(1);
659 assert_eq!(s.encode_policy, EncodePolicy::SingleGpu(Some(1)));
660 assert_eq!(s.gpu_index, Some(1));
661 }
662
663 #[test]
664 fn encode_policy_family_does_not_pin_gpu_index() {
665 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
666 .encode_policy(EncodePolicy::Family(GpuFamily::Nvidia));
667 assert_eq!(s.encode_policy, EncodePolicy::Family(GpuFamily::Nvidia));
668 // Family is multi-GPU within a vendor — no single-GPU pin.
669 assert_eq!(s.gpu_index, None);
670 }
671
672 #[test]
673 fn decode_gpu_defaults_to_none_and_is_settable() {
674 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
675 assert_eq!(s.decode_gpu, None);
676 let s = s.decode_gpu(Some(0));
677 assert_eq!(s.decode_gpu, Some(0));
678 // decode_gpu is independent of the encode policy.
679 assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
680 }
681
682 #[test]
683 fn encode_policy_all_gpus_leaves_gpu_index_untouched() {
684 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
685 .with_gpu_index(3)
686 .encode_policy(EncodePolicy::AllGpus);
687 // AllGpus doesn't clear an explicit pin; it just won't single-pin.
688 assert_eq!(s.encode_policy, EncodePolicy::AllGpus);
689 assert_eq!(s.gpu_index, Some(3));
690 }
691
692 #[test]
693 fn hls_sets_coherent_fields() {
694 let s = OutputSpec::hls(vec![Rung::new(1920, 1080), Rung::new(640, 360)], 4.0);
695 assert!(matches!(s.mode, OutputMode::Hls { .. }));
696 assert_eq!(s.container, Container::Cmaf);
697 assert_eq!(s.muxer, Muxer::CmafHls);
698 assert!(s.validate().is_ok());
699 }
700
701 #[test]
702 fn validate_rejects_empty_rungs() {
703 assert!(OutputSpec::single_file(vec![]).validate().is_err());
704 }
705
706 #[test]
707 fn validate_rejects_odd_dimensions() {
708 assert!(OutputSpec::single_file(vec![Rung::new(1281, 720)]).validate().is_err());
709 }
710
711 #[test]
712 fn validate_rejects_incoherent_mode_muxer() {
713 let mut s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
714 s.muxer = Muxer::CmafHls; // mismatched with SingleFile mode
715 assert!(s.validate().is_err());
716 }
717
718 #[test]
719 fn rung_label_uses_short_side() {
720 assert_eq!(Rung::new(1920, 1080).label, "1080p");
721 assert_eq!(Rung::new(1080, 1920).label, "1080p");
722 assert_eq!(Rung::new(640, 360).short_side(), 360);
723 }
724
725 #[test]
726 fn color_and_pixel_format_default_to_sdr_8bit() {
727 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
728 assert_eq!(s.color, ColorPolicy::TonemapToSdr);
729 assert_eq!(s.bit_depth, BitDepth::Auto);
730 assert!(s.tonemaps());
731 assert!(s.validate().is_ok());
732 }
733
734 #[test]
735 fn resolve_output_default_folds_hdr_source_to_sdr_8bit() {
736 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]);
737 let hdr_src = hdr_metadata(TransferFn::St2084);
738 let (color, pix) = s.resolve_output(hdr_src, PixelFormat::Yuv420p10le);
739 // Default TonemapToSdr collapses an HDR 10-bit source to 8-bit SDR.
740 assert_eq!(color.transfer, TransferFn::Bt709);
741 assert_eq!(pix, PixelFormat::Yuv420p);
742 }
743
744 #[test]
745 fn resolve_output_passthrough_keeps_source() {
746 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_color(ColorPolicy::Passthrough);
747 assert!(!s.tonemaps());
748 let src = hdr_metadata(TransferFn::St2084);
749 let (color, pix) = s.resolve_output(src, PixelFormat::Yuv420p10le);
750 assert_eq!(color.transfer, TransferFn::St2084);
751 assert_eq!(pix, PixelFormat::Yuv420p10le);
752 }
753
754 #[test]
755 fn validate_rejects_hdr_without_10bit_or_ffmpeg() {
756 // HDR10 implies 10-bit; without the `ffmpeg` feature the build is 8-bit,
757 // so validation must reject it on a default build.
758 let s = OutputSpec::single_file(vec![Rung::new(640, 360)]).with_color(ColorPolicy::Hdr10);
759 let caps = codec::encode::build_output_caps();
760 if caps.max_bit_depth < 10 {
761 assert!(s.validate().is_err(), "HDR must be rejected on an 8-bit-only build");
762 } else {
763 assert!(s.validate().is_ok());
764 }
765 }
766
767 #[test]
768 fn validate_rejects_hdr_forced_8bit() {
769 let s = OutputSpec::single_file(vec![Rung::new(640, 360)])
770 .with_color(ColorPolicy::Hdr10)
771 .with_bit_depth(BitDepth::EightBit);
772 assert!(s.validate().is_err());
773 }
774
775 #[test]
776 fn quality_crf_applies_to_encoder_config() {
777 let q = Quality::crf(28);
778 let mut cfg = EncoderConfig::default();
779 q.apply(&mut cfg, 30.0);
780 assert_eq!(cfg.quality, 28);
781 assert_eq!(cfg.keyframe_interval, 60); // 2 * 30
782 }
783}