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