1use anyhow::{Context, Result, bail};
10
11use crate::spec::{
12 AudioPolicy, BitDepth, ChunkSeamMode, ColorPolicy, EncodePolicy, GpuFamily, OutputSpec, Quality,
13 Rung,
14};
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Mode {
19 Single,
20 Hls,
21}
22
23#[derive(Debug, Clone, Default)]
26pub struct TranscodeSettings {
27 pub mode: Option<Mode>,
28 pub rungs: Vec<(u32, u32)>,
30 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 pub gpu: Option<u32>,
43 pub gpu_family: Option<GpuFamily>,
45 pub single_gpu: bool,
47 pub decode_gpu: Option<u32>,
49 pub width: Option<u32>,
52 pub height: Option<u32>,
53}
54
55impl TranscodeSettings {
56 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 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 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 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 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
191pub 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
247pub 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}