Skip to main content

rivet/
settings.rs

1//! One canonical definition of the transcode "knobs", shared by every
2//! front-end — the CLI (`transcode` / `pipe`), the HTTP API, and the IPC
3//! socket. Each surface parses its own syntax (clap flags / JSON / query
4//! string / `key=value`) into a [`TranscodeSettings`], then calls
5//! [`TranscodeSettings::into_spec`]. Add a new option **once** here (a field +
6//! a line in `into_spec` + a `parse_*` arm) and every surface picks it up,
7//! instead of maintaining three copies of the spec-building logic.
8
9use anyhow::{Context, Result, bail};
10
11use crate::spec::{
12    AudioPolicy, BitDepth, ChunkSeamMode, ColorPolicy, EncodePolicy, GpuFamily, OutputSpec, Quality,
13    Rung,
14};
15
16/// Output mode.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Mode {
19    Single,
20    Hls,
21}
22
23/// Every optional transcode knob, surface-agnostic. All-`None`/empty is "use the
24/// defaults" (source-resolution single file, AV1 + audio passthrough, SDR).
25#[derive(Debug, Clone, Default)]
26pub struct TranscodeSettings {
27    pub mode: Option<Mode>,
28    /// Explicit rungs as `(width, height)`. Wins over `ladder` / `width`.
29    pub rungs: Vec<(u32, u32)>,
30    /// Derive a standard ABR ladder from the source.
31    pub ladder: bool,
32    pub max_short_side: Option<u32>,
33    pub segment_seconds: Option<f32>,
34    pub crf: Option<u8>,
35    pub speed: Option<u8>,
36    pub audio: Option<AudioPolicy>,
37    pub color: Option<ColorPolicy>,
38    pub bit_depth: Option<BitDepth>,
39    pub seam: Option<ChunkSeamMode>,
40    pub max_fps: Option<f64>,
41    /// Pin encode to one GPU index.
42    pub gpu: Option<u32>,
43    /// Restrict encode to one vendor family.
44    pub gpu_family: Option<GpuFamily>,
45    /// Use a single GPU (serial), the first available.
46    pub single_gpu: bool,
47    /// Pin the decode pump to a GPU index.
48    pub decode_gpu: Option<u32>,
49    /// Single-output width/height (the `pipe`/`ipc` scaling knobs). Used only
50    /// when neither `rungs` nor `ladder` is set; defaults to the source size.
51    pub width: Option<u32>,
52    pub height: Option<u32>,
53}
54
55impl TranscodeSettings {
56    /// Build an [`OutputSpec`] from these settings against a source resolution.
57    /// This is the **single** spec-building implementation for all surfaces.
58    pub fn into_spec(self, src_w: u32, src_h: u32) -> Result<OutputSpec> {
59        let quality = Quality {
60            crf: self.crf,
61            speed_preset: self.speed,
62            ..Default::default()
63        };
64
65        let rungs: Vec<Rung> = if !self.rungs.is_empty() {
66            self.rungs
67                .iter()
68                .map(|&(w, h)| Rung::new(w, h).with_quality(quality.clone()))
69                .collect()
70        } else if self.ladder {
71            crate::ladder::standard_ladder(src_w, src_h, self.max_short_side)
72                .into_iter()
73                .map(|r| r.with_quality(quality.clone()))
74                .collect()
75        } else {
76            // Single rung at the requested size, else the source — even-aligned
77            // (AV1 4:2:0 needs even dimensions).
78            let w = self.width.unwrap_or(src_w) & !1;
79            let h = self.height.unwrap_or(src_h) & !1;
80            if w == 0 || h == 0 {
81                bail!("source resolution unknown ({src_w}x{src_h}); set explicit rungs or width/height");
82            }
83            vec![Rung::new(w, h).with_quality(quality.clone())]
84        };
85        if rungs.is_empty() {
86            bail!("no rungs to produce");
87        }
88
89        let mut spec = match self.mode.unwrap_or(Mode::Single) {
90            Mode::Hls => OutputSpec::hls(rungs, self.segment_seconds.unwrap_or(4.0)),
91            Mode::Single => OutputSpec::single_file(rungs),
92        };
93
94        if let Some(a) = self.audio {
95            spec.audio = a;
96        }
97        spec.max_frame_rate = self.max_fps;
98        if let Some(c) = self.color {
99            spec = spec.with_color(c);
100        }
101        if let Some(b) = self.bit_depth {
102            spec = spec.with_bit_depth(b);
103        }
104        if let Some(s) = self.seam {
105            spec = spec.chunk_seam_mode(s);
106        }
107
108        // GPU policy precedence: pinned index > vendor family > single > all.
109        spec = if let Some(idx) = self.gpu {
110            spec.encode_policy(EncodePolicy::SingleGpu(Some(idx)))
111        } else if let Some(fam) = self.gpu_family {
112            spec.encode_policy(EncodePolicy::Family(fam))
113        } else if self.single_gpu {
114            spec.encode_policy(EncodePolicy::SingleGpu(None))
115        } else {
116            spec.encode_policy(EncodePolicy::AllGpus)
117        };
118        spec = spec.decode_gpu(self.decode_gpu);
119
120        spec.validate().context("invalid output spec")?;
121        Ok(spec)
122    }
123
124    /// Apply one `key=value` setting (the IPC header / generic string form).
125    /// Keys mirror the CLI flags. Unknown keys error.
126    pub fn apply_kv(&mut self, key: &str, val: &str) -> Result<()> {
127        match key {
128            "mode" => self.mode = Some(parse_mode(val)?),
129            "rung" | "rungs" => {
130                for r in val.split(',').map(str::trim).filter(|s| !s.is_empty()) {
131                    self.rungs.push(parse_rung(r)?);
132                }
133            }
134            "ladder" => self.ladder = parse_bool(val),
135            "max-short-side" => self.max_short_side = Some(val.parse().context("max-short-side")?),
136            "segment-seconds" => self.segment_seconds = Some(val.parse().context("segment-seconds")?),
137            "crf" => self.crf = Some(val.parse().context("crf")?),
138            "speed" => self.speed = Some(val.parse().context("speed")?),
139            "audio" => self.audio = Some(parse_audio(val)?),
140            "color" => self.color = Some(parse_color(val)?),
141            "bit-depth" | "pixel-format" => self.bit_depth = Some(parse_bit_depth(val)?),
142            "seam" => self.seam = Some(parse_seam(val)?),
143            "max-fps" => self.max_fps = Some(val.parse().context("max-fps")?),
144            "gpu" => self.gpu = Some(val.parse().context("gpu")?),
145            "gpu-family" => self.gpu_family = Some(parse_gpu_family(val)?),
146            "single-gpu" => self.single_gpu = parse_bool(val),
147            "decode-gpu" => self.decode_gpu = Some(val.parse().context("decode-gpu")?),
148            "width" => self.width = Some(val.parse().context("width")?),
149            "height" => self.height = Some(val.parse().context("height")?),
150            o => bail!(
151                "unknown setting '{o}' (mode/rung/ladder/crf/speed/audio/color/bit-depth/seam/max-fps/gpu/gpu-family/single-gpu/decode-gpu/width/height)"
152            ),
153        }
154        Ok(())
155    }
156
157    /// Parse a whole `key=value key=value …` line into settings.
158    pub fn parse_kv_line(line: &str) -> Result<Self> {
159        let mut s = Self::default();
160        for tok in line.split_whitespace() {
161            let (k, v) = tok
162                .split_once('=')
163                .with_context(|| format!("bad setting '{tok}' (expected key=value)"))?;
164            s.apply_kv(k, v)?;
165        }
166        Ok(s)
167    }
168
169    pub fn is_empty(&self) -> bool {
170        self.mode.is_none()
171            && self.rungs.is_empty()
172            && !self.ladder
173            && self.max_short_side.is_none()
174            && self.segment_seconds.is_none()
175            && self.crf.is_none()
176            && self.speed.is_none()
177            && self.audio.is_none()
178            && self.color.is_none()
179            && self.bit_depth.is_none()
180            && self.seam.is_none()
181            && self.max_fps.is_none()
182            && self.gpu.is_none()
183            && self.gpu_family.is_none()
184            && !self.single_gpu
185            && self.decode_gpu.is_none()
186            && self.width.is_none()
187            && self.height.is_none()
188    }
189}
190
191// ── central string vocabulary (the single source of truth) ──────────────
192
193pub fn parse_mode(s: &str) -> Result<Mode> {
194    match s {
195        "single" => Ok(Mode::Single),
196        "hls" => Ok(Mode::Hls),
197        o => bail!("mode must be single|hls, got '{o}'"),
198    }
199}
200
201pub fn parse_audio(s: &str) -> Result<AudioPolicy> {
202    match s {
203        "auto" => Ok(AudioPolicy::Auto),
204        "opus" => Ok(AudioPolicy::ForceOpus),
205        "drop" => Ok(AudioPolicy::Drop),
206        o => bail!("audio must be auto|opus|drop, got '{o}'"),
207    }
208}
209
210pub fn parse_color(s: &str) -> Result<ColorPolicy> {
211    match s {
212        "sdr" => Ok(ColorPolicy::TonemapToSdr),
213        "hdr10" => Ok(ColorPolicy::Hdr10),
214        "hlg" => Ok(ColorPolicy::Hlg),
215        "passthrough" => Ok(ColorPolicy::Passthrough),
216        o => bail!("color must be sdr|hdr10|hlg|passthrough, got '{o}'"),
217    }
218}
219
220pub fn parse_bit_depth(s: &str) -> Result<BitDepth> {
221    match s {
222        "auto" => Ok(BitDepth::Auto),
223        "8bit" => Ok(BitDepth::EightBit),
224        "10bit" => Ok(BitDepth::TenBit),
225        o => bail!("bit-depth must be auto|8bit|10bit, got '{o}'"),
226    }
227}
228
229pub fn parse_seam(s: &str) -> Result<ChunkSeamMode> {
230    match s {
231        "parallel" => Ok(ChunkSeamMode::Parallel),
232        "constqp" => Ok(ChunkSeamMode::ParallelConstQp),
233        "serial" => Ok(ChunkSeamMode::Serial),
234        o => bail!("seam must be parallel|constqp|serial, got '{o}'"),
235    }
236}
237
238pub fn parse_gpu_family(s: &str) -> Result<GpuFamily> {
239    match s {
240        "nvidia" => Ok(GpuFamily::Nvidia),
241        "amd" => Ok(GpuFamily::Amd),
242        "intel" => Ok(GpuFamily::Intel),
243        o => bail!("gpu-family must be nvidia|amd|intel, got '{o}'"),
244    }
245}
246
247/// Parse a `WxH` rung, e.g. `1280x720`.
248pub fn parse_rung(s: &str) -> Result<(u32, u32)> {
249    let (w, h) = s
250        .split_once(['x', 'X'])
251        .with_context(|| format!("rung must be WxH, e.g. 1280x720 (got '{s}')"))?;
252    Ok((
253        w.trim().parse().context("rung width")?,
254        h.trim().parse().context("rung height")?,
255    ))
256}
257
258fn parse_bool(s: &str) -> bool {
259    matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" | "y" | "t")
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265
266    #[test]
267    fn defaults_to_single_source_resolution() {
268        let spec = TranscodeSettings::default().into_spec(1280, 720).unwrap();
269        assert!(matches!(spec.mode, crate::spec::OutputMode::SingleFile));
270        assert_eq!(spec.rungs.len(), 1);
271        assert_eq!((spec.rungs[0].width, spec.rungs[0].height), (1280, 720));
272    }
273
274    #[test]
275    fn explicit_rungs_and_hls() {
276        let s = TranscodeSettings {
277            mode: Some(Mode::Hls),
278            rungs: vec![(1920, 1080), (1280, 720), (640, 360)],
279            segment_seconds: Some(6.0),
280            crf: Some(28),
281            ..Default::default()
282        };
283        let spec = s.into_spec(1920, 1080).unwrap();
284        assert!(matches!(spec.mode, crate::spec::OutputMode::Hls { .. }));
285        assert_eq!(spec.rungs.len(), 3);
286        assert_eq!(spec.rungs[1].quality.crf, Some(28));
287    }
288
289    #[test]
290    fn width_height_scales_single_rung() {
291        let s = TranscodeSettings {
292            width: Some(640),
293            height: Some(360),
294            ..Default::default()
295        };
296        let spec = s.into_spec(1280, 720).unwrap();
297        assert_eq!((spec.rungs[0].width, spec.rungs[0].height), (640, 360));
298    }
299
300    #[test]
301    fn kv_line_parses_all_common_keys() {
302        let s = TranscodeSettings::parse_kv_line(
303            "mode=hls rung=1280x720,640x360 crf=30 audio=opus gpu=1 max-fps=30",
304        )
305        .unwrap();
306        assert_eq!(s.mode, Some(Mode::Hls));
307        assert_eq!(s.rungs, vec![(1280, 720), (640, 360)]);
308        assert_eq!(s.crf, Some(30));
309        assert_eq!(s.audio, Some(AudioPolicy::ForceOpus));
310        assert_eq!(s.gpu, Some(1));
311        assert_eq!(s.max_fps, Some(30.0));
312    }
313
314    #[test]
315    fn kv_rejects_unknown_key() {
316        assert!(TranscodeSettings::parse_kv_line("bogus=1").is_err());
317        assert!(TranscodeSettings::parse_kv_line("crf=notanumber").is_err());
318    }
319
320    #[test]
321    fn parsers_reject_garbage() {
322        assert!(parse_color("ultrahd").is_err());
323        assert!(parse_rung("notarung").is_err());
324        assert!(parse_rung("1280x720").is_ok());
325    }
326}