Skip to main content

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}