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    AudioCodecPolicy, BitDepth, ChunkSeamMode, ColorPolicy, DecodePolicy, EncodePolicy, GpuFamily,
13    OutputSpec, Quality, 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<AudioCodecPolicy>,
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    /// How the decode pump picks its GPU: `Auto` (follow the encode policy),
48    /// `SpecificGpu(i)`, or `FastestGpu` (benchmark up front). See [`DecodePolicy`].
49    pub decode_policy: DecodePolicy,
50    /// Single-output width/height (the `pipe`/`ipc` scaling knobs). Used only
51    /// when neither `rungs` nor `ladder` is set; defaults to the source size.
52    pub width: Option<u32>,
53    pub height: Option<u32>,
54    /// Video filter chain (crop/pad/flip/rotate/grayscale) applied before
55    /// per-rung scaling. The canonical structured form; string surfaces parse
56    /// `codec::filter::parse_chain` at the edge.
57    pub filters: Vec<codec::filter::VideoFilter>,
58    /// Output video codec: `av1` (default), `h264`, or `h265`. `None` = av1.
59    pub video_codec: Option<crate::spec::VideoCodecPolicy>,
60    /// Splice **trim in-point** in seconds (`None` = start of input).
61    pub trim_start: Option<f64>,
62    /// Splice **trim out-point** in seconds (`None` = end of input).
63    pub trim_end: Option<f64>,
64}
65
66impl TranscodeSettings {
67    /// Build an [`OutputSpec`] from these settings against a source resolution.
68    /// This is the **single** spec-building implementation for all surfaces.
69    pub fn into_spec(self, src_w: u32, src_h: u32) -> Result<OutputSpec> {
70        let quality = Quality {
71            crf: self.crf,
72            speed_preset: self.speed,
73            ..Default::default()
74        };
75
76        let rungs: Vec<Rung> = if !self.rungs.is_empty() {
77            self.rungs
78                .iter()
79                .map(|&(w, h)| Rung::new(w, h).with_quality(quality.clone()))
80                .collect()
81        } else if self.ladder {
82            crate::ladder::standard_ladder(src_w, src_h, self.max_short_side)
83                .into_iter()
84                .map(|r| r.with_quality(quality.clone()))
85                .collect()
86        } else {
87            // Single rung at the requested size, else the source — even-aligned
88            // (AV1 4:2:0 needs even dimensions).
89            let w = self.width.unwrap_or(src_w) & !1;
90            let h = self.height.unwrap_or(src_h) & !1;
91            if w == 0 || h == 0 {
92                bail!("source resolution unknown ({src_w}x{src_h}); set explicit rungs or width/height");
93            }
94            vec![Rung::new(w, h).with_quality(quality.clone())]
95        };
96        if rungs.is_empty() {
97            bail!("no rungs to produce");
98        }
99
100        let mut spec = match self.mode.unwrap_or(Mode::Single) {
101            Mode::Hls => OutputSpec::hls(rungs, self.segment_seconds.unwrap_or(4.0)),
102            Mode::Single => OutputSpec::single_file(rungs),
103        };
104
105        if let Some(a) = self.audio {
106            spec.audio = a;
107        }
108        spec.max_frame_rate = self.max_fps;
109        if let Some(c) = self.color {
110            spec = spec.with_color(c);
111        }
112        if let Some(b) = self.bit_depth {
113            spec = spec.with_bit_depth(b);
114        }
115        if let Some(s) = self.seam {
116            spec = spec.chunk_seam_mode(s);
117        }
118
119        // GPU policy precedence: pinned index > vendor family > single > all.
120        spec = if let Some(idx) = self.gpu {
121            spec.encode_policy(EncodePolicy::SingleGpu(Some(idx)))
122        } else if let Some(fam) = self.gpu_family {
123            spec.encode_policy(EncodePolicy::Family(fam))
124        } else if self.single_gpu {
125            spec.encode_policy(EncodePolicy::SingleGpu(None))
126        } else {
127            spec.encode_policy(EncodePolicy::AllGpus)
128        };
129        spec = spec.decode_policy(self.decode_policy);
130        spec = spec.with_filters(self.filters);
131        spec = spec.with_trim(self.trim_start, self.trim_end);
132        if let Some(c) = self.video_codec {
133            spec = spec.with_video_codec(c);
134        }
135
136        spec.validate().context("invalid output spec")?;
137        Ok(spec)
138    }
139
140    /// Apply one `key=value` setting (the IPC header / generic string form).
141    /// Keys mirror the CLI flags. Unknown keys error.
142    pub fn apply_kv(&mut self, key: &str, val: &str) -> Result<()> {
143        match key {
144            "mode" => self.mode = Some(parse_mode(val)?),
145            "rung" | "rungs" => {
146                for r in val.split(',').map(str::trim).filter(|s| !s.is_empty()) {
147                    self.rungs.push(parse_rung(r)?);
148                }
149            }
150            "ladder" => self.ladder = parse_bool(val),
151            "max-short-side" => self.max_short_side = Some(val.parse().context("max-short-side")?),
152            "segment-seconds" => self.segment_seconds = Some(val.parse().context("segment-seconds")?),
153            "crf" => self.crf = Some(val.parse().context("crf")?),
154            "speed" => self.speed = Some(val.parse().context("speed")?),
155            "audio" => self.audio = Some(parse_audio(val)?),
156            "color" => self.color = Some(parse_color(val)?),
157            "bit-depth" | "pixel-format" => self.bit_depth = Some(parse_bit_depth(val)?),
158            "seam" => self.seam = Some(parse_seam(val)?),
159            "max-fps" => self.max_fps = Some(val.parse().context("max-fps")?),
160            "gpu" => self.gpu = Some(val.parse().context("gpu")?),
161            "gpu-family" => self.gpu_family = Some(parse_gpu_family(val)?),
162            "single-gpu" => self.single_gpu = parse_bool(val),
163            "decode-gpu" => {
164                self.decode_policy = val.parse().map_err(anyhow::Error::msg).context("decode-gpu")?
165            }
166            "width" => self.width = Some(val.parse().context("width")?),
167            "height" => self.height = Some(val.parse().context("height")?),
168            "filter" => self.filters = codec::filter::parse_chain(val)?,
169            "codec" => self.video_codec = Some(parse_video_codec(val)?),
170            o => bail!(
171                "unknown setting '{o}' (mode/rung/ladder/crf/speed/audio/color/bit-depth/seam/max-fps/gpu/gpu-family/single-gpu/decode-gpu/width/height/filter/codec)"
172            ),
173        }
174        Ok(())
175    }
176
177    /// Parse a whole `key=value key=value …` line into settings.
178    pub fn parse_kv_line(line: &str) -> Result<Self> {
179        let mut s = Self::default();
180        for tok in line.split_whitespace() {
181            let (k, v) = tok
182                .split_once('=')
183                .with_context(|| format!("bad setting '{tok}' (expected key=value)"))?;
184            s.apply_kv(k, v)?;
185        }
186        Ok(s)
187    }
188
189    pub fn is_empty(&self) -> bool {
190        self.mode.is_none()
191            && self.rungs.is_empty()
192            && !self.ladder
193            && self.max_short_side.is_none()
194            && self.segment_seconds.is_none()
195            && self.crf.is_none()
196            && self.speed.is_none()
197            && self.audio.is_none()
198            && self.color.is_none()
199            && self.bit_depth.is_none()
200            && self.seam.is_none()
201            && self.max_fps.is_none()
202            && self.gpu.is_none()
203            && self.gpu_family.is_none()
204            && !self.single_gpu
205            && self.decode_policy == DecodePolicy::Auto
206            && self.width.is_none()
207            && self.height.is_none()
208            && self.filters.is_empty()
209            && self.video_codec.is_none()
210    }
211}
212
213// ── central string vocabulary (the single source of truth) ──────────────
214
215pub fn parse_mode(s: &str) -> Result<Mode> {
216    match s {
217        "single" => Ok(Mode::Single),
218        "hls" => Ok(Mode::Hls),
219        o => bail!("mode must be single|hls, got '{o}'"),
220    }
221}
222
223pub fn parse_audio(s: &str) -> Result<AudioCodecPolicy> {
224    match s {
225        "auto" => Ok(AudioCodecPolicy::Auto),
226        "opus" => Ok(AudioCodecPolicy::ForceOpus),
227        "drop" => Ok(AudioCodecPolicy::Drop),
228        o => bail!("audio must be auto|opus|drop, got '{o}'"),
229    }
230}
231
232pub fn parse_color(s: &str) -> Result<ColorPolicy> {
233    match s {
234        "sdr" => Ok(ColorPolicy::TonemapToSdr),
235        "hdr10" => Ok(ColorPolicy::Hdr10),
236        "hlg" => Ok(ColorPolicy::Hlg),
237        "passthrough" => Ok(ColorPolicy::Passthrough),
238        o => bail!("color must be sdr|hdr10|hlg|passthrough, got '{o}'"),
239    }
240}
241
242pub fn parse_bit_depth(s: &str) -> Result<BitDepth> {
243    match s {
244        "auto" => Ok(BitDepth::Auto),
245        "8bit" => Ok(BitDepth::EightBit),
246        "10bit" => Ok(BitDepth::TenBit),
247        o => bail!("bit-depth must be auto|8bit|10bit, got '{o}'"),
248    }
249}
250
251pub fn parse_seam(s: &str) -> Result<ChunkSeamMode> {
252    match s {
253        "parallel" => Ok(ChunkSeamMode::Parallel),
254        "constqp" => Ok(ChunkSeamMode::ParallelConstQp),
255        "serial" => Ok(ChunkSeamMode::Serial),
256        o => bail!("seam must be parallel|constqp|serial, got '{o}'"),
257    }
258}
259
260pub fn parse_video_codec(s: &str) -> Result<crate::spec::VideoCodecPolicy> {
261    use crate::spec::VideoCodecPolicy;
262    match s.to_ascii_lowercase().as_str() {
263        "av1" | "av01" => Ok(VideoCodecPolicy::Av1),
264        "h264" | "avc" | "avc1" | "x264" => Ok(VideoCodecPolicy::H264),
265        "h265" | "hevc" | "hvc1" | "x265" => Ok(VideoCodecPolicy::H265),
266        o => bail!("codec must be av1|h264|h265, got '{o}'"),
267    }
268}
269
270pub fn parse_gpu_family(s: &str) -> Result<GpuFamily> {
271    match s {
272        "nvidia" => Ok(GpuFamily::Nvidia),
273        "amd" => Ok(GpuFamily::Amd),
274        "intel" => Ok(GpuFamily::Intel),
275        o => bail!("gpu-family must be nvidia|amd|intel, got '{o}'"),
276    }
277}
278
279/// Parse a `WxH` rung, e.g. `1280x720`.
280pub fn parse_rung(s: &str) -> Result<(u32, u32)> {
281    let (w, h) = s
282        .split_once(['x', 'X'])
283        .with_context(|| format!("rung must be WxH, e.g. 1280x720 (got '{s}')"))?;
284    Ok((
285        w.trim().parse().context("rung width")?,
286        h.trim().parse().context("rung height")?,
287    ))
288}
289
290fn parse_bool(s: &str) -> bool {
291    matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on" | "y" | "t")
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297
298    #[test]
299    fn defaults_to_single_source_resolution() {
300        let spec = TranscodeSettings::default().into_spec(1280, 720).unwrap();
301        assert!(matches!(spec.mode, crate::spec::OutputMode::SingleFile));
302        assert_eq!(spec.rungs.len(), 1);
303        assert_eq!((spec.rungs[0].width, spec.rungs[0].height), (1280, 720));
304    }
305
306    #[test]
307    fn explicit_rungs_and_hls() {
308        let s = TranscodeSettings {
309            mode: Some(Mode::Hls),
310            rungs: vec![(1920, 1080), (1280, 720), (640, 360)],
311            segment_seconds: Some(6.0),
312            crf: Some(28),
313            ..Default::default()
314        };
315        let spec = s.into_spec(1920, 1080).unwrap();
316        assert!(matches!(spec.mode, crate::spec::OutputMode::Hls { .. }));
317        assert_eq!(spec.rungs.len(), 3);
318        assert_eq!(spec.rungs[1].quality.crf, Some(28));
319    }
320
321    #[test]
322    fn width_height_scales_single_rung() {
323        let s = TranscodeSettings {
324            width: Some(640),
325            height: Some(360),
326            ..Default::default()
327        };
328        let spec = s.into_spec(1280, 720).unwrap();
329        assert_eq!((spec.rungs[0].width, spec.rungs[0].height), (640, 360));
330    }
331
332    #[test]
333    fn kv_line_parses_all_common_keys() {
334        let s = TranscodeSettings::parse_kv_line(
335            "mode=hls rung=1280x720,640x360 crf=30 audio=opus gpu=1 max-fps=30",
336        )
337        .unwrap();
338        assert_eq!(s.mode, Some(Mode::Hls));
339        assert_eq!(s.rungs, vec![(1280, 720), (640, 360)]);
340        assert_eq!(s.crf, Some(30));
341        assert_eq!(s.audio, Some(AudioCodecPolicy::ForceOpus));
342        assert_eq!(s.gpu, Some(1));
343        assert_eq!(s.max_fps, Some(30.0));
344    }
345
346    #[test]
347    fn kv_rejects_unknown_key() {
348        assert!(TranscodeSettings::parse_kv_line("bogus=1").is_err());
349        assert!(TranscodeSettings::parse_kv_line("crf=notanumber").is_err());
350    }
351
352    #[test]
353    fn parsers_reject_garbage() {
354        assert!(parse_color("ultrahd").is_err());
355        assert!(parse_rung("notarung").is_err());
356        assert!(parse_rung("1280x720").is_ok());
357    }
358}