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    /// 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}