1use anyhow::{Context, Result, bail};
10
11use crate::spec::{
12 AudioCodecPolicy, BitDepth, ChunkSeamMode, ColorPolicy, DecodePolicy, EncodePolicy, GpuFamily,
13 OutputSpec, Quality, 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<AudioCodecPolicy>,
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_policy: DecodePolicy,
50 pub width: Option<u32>,
53 pub height: Option<u32>,
54 pub filters: Vec<codec::filter::VideoFilter>,
58 pub video_codec: Option<crate::spec::VideoCodecPolicy>,
60 pub trim_start: Option<f64>,
62 pub trim_end: Option<f64>,
64}
65
66impl TranscodeSettings {
67 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 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 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 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 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
213pub 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
279pub 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}