Skip to main content

viser_ffmpeg/
encode.rs

1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6use crate::{Codec, EncoderBackend, RateControlMode, Resolution, ffmpeg_path, probe};
7
8/// Parameters for a single encode.
9#[derive(Debug, Clone)]
10pub struct EncodeJob {
11    /// Source media file path.
12    pub input: String,
13    /// Destination file path for the encoded output.
14    pub output: String,
15    /// Optional target resolution; when set, scales with the lanczos filter.
16    pub resolution: Option<Resolution>,
17    /// Video codec to encode with.
18    pub codec: Codec,
19    /// Constant rate factor / quantizer value (interpretation depends on `rate_control`).
20    pub crf: i32,
21    /// Rate-control mode that determines how `crf`/bitrate fields are applied.
22    pub rate_control: RateControlMode,
23    /// Target bitrate in kbps; used for VBR mode.
24    pub target_bitrate: f64, // kbps, used for VBR mode
25    /// Maximum bitrate cap in kbps; used for capped CRF mode.
26    pub max_bitrate: f64, // kbps, used for capped CRF mode
27    /// VBV buffer size in kbps; used for capped CRF mode.
28    pub bufsize: f64, // kbps, used for capped CRF mode
29    /// Encoder speed preset (e.g. `"medium"`); empty leaves the encoder default.
30    pub preset: String,
31    /// Optional hardware-accelerated decode method (e.g. `"vaapi"`, `"cuda"`,
32    /// `"qsv"`, `"videotoolbox"`). `None` (or empty) decodes in software.
33    /// Frames are downloaded to system memory for the filter/encode pipeline.
34    pub hwaccel: Option<String>,
35    /// Extra raw FFmpeg arguments appended verbatim before the output path.
36    pub extra_args: Vec<String>,
37}
38
39/// Output of a completed encode.
40#[derive(Debug, Clone)]
41pub struct EncodeResult {
42    /// The job that produced this result.
43    pub job: EncodeJob,
44    /// Average bitrate of the output in kbps, measured by probing it.
45    pub bitrate: f64, // kbps (average)
46    /// Output file size in bytes.
47    pub file_size: u64, // bytes
48    /// Wall-clock time taken to encode.
49    pub duration: Duration, // wall-clock encode time
50}
51
52/// Real-time encoding progress info parsed from FFmpeg.
53#[derive(Debug, Clone, Default)]
54pub struct Progress {
55    /// Number of frames encoded so far.
56    pub frame: i64,
57    /// Current encoding rate in frames per second.
58    pub fps: f64,
59    /// Current output bitrate in kbps.
60    pub bitrate: f64, // kbps
61    /// Encoding speed relative to real time (e.g. 2.5 means 2.5x).
62    pub speed: f64, // e.g. 2.5x
63    /// Output timestamp reached so far.
64    pub time: Duration,
65}
66
67/// Runs an FFmpeg encode job. Progress updates are sent on the channel if provided.
68pub async fn encode(
69    job: EncodeJob,
70    progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
71) -> anyhow::Result<EncodeResult> {
72    match job.rate_control {
73        RateControlMode::Vbr => encode_two_pass(job, progress_tx).await,
74        _ => encode_single_pass(job, progress_tx).await,
75    }
76}
77
78async fn encode_single_pass(
79    job: EncodeJob,
80    progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
81) -> anyhow::Result<EncodeResult> {
82    let args = build_encode_args(&job, EncodePass::Single)?;
83    run_encode(job, args, progress_tx).await
84}
85
86async fn encode_two_pass(
87    job: EncodeJob,
88    progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
89) -> anyhow::Result<EncodeResult> {
90    if job.target_bitrate <= 0.0 {
91        anyhow::bail!("target bitrate must be greater than zero for VBR mode");
92    }
93
94    let passlog_prefix = make_passlog_prefix(&job.output);
95    let cleanup = PasslogCleanup::new(passlog_prefix.clone());
96
97    let first_pass_args = build_encode_args(&job, EncodePass::First(&passlog_prefix))?;
98    run_ffmpeg(first_pass_args, None).await?;
99
100    let second_pass_args = build_encode_args(&job, EncodePass::Second(&passlog_prefix))?;
101    let result = run_encode(job, second_pass_args, progress_tx).await;
102
103    cleanup.run();
104    result
105}
106
107async fn run_encode(
108    job: EncodeJob,
109    args: Vec<String>,
110    progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
111) -> anyhow::Result<EncodeResult> {
112    let start = Instant::now();
113    run_ffmpeg(args, progress_tx).await?;
114
115    let elapsed = start.elapsed();
116
117    // Probe the output to get actual bitrate and file size
118    let meta = std::fs::metadata(&job.output)
119        .map_err(|e| anyhow::anyhow!("failed to stat output: {e}"))?;
120
121    let probe_result = probe(&job.output).await?;
122    let bitrate = probe_result.format.bit_rate as f64 / 1000.0;
123
124    Ok(EncodeResult { job, bitrate, file_size: meta.len(), duration: elapsed })
125}
126
127async fn run_ffmpeg(
128    args: Vec<String>,
129    progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
130) -> anyhow::Result<()> {
131    let mut cmd = Command::new(ffmpeg_path());
132    cmd.args(&args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
133
134    let mut child = cmd.spawn().map_err(|e| anyhow::anyhow!("failed to start ffmpeg: {e}"))?;
135
136    // Parse progress from stdout
137    if let Some(stdout) = child.stdout.take() {
138        let tx = progress_tx.clone();
139        tokio::spawn(async move {
140            let reader = tokio::io::BufReader::new(stdout);
141            let mut lines = reader.lines();
142            let mut p = Progress::default();
143            while let Ok(Some(line)) = lines.next_line().await {
144                if parse_progress_line(&line, &mut p)
145                    && let Some(ref tx) = tx
146                {
147                    let _ = tx.try_send(p.clone());
148                }
149            }
150        });
151    }
152
153    let output = child.wait_with_output().await?;
154    if !output.status.success() {
155        let stderr = String::from_utf8_lossy(&output.stderr);
156        anyhow::bail!("ffmpeg encode failed: {stderr}");
157    }
158
159    Ok(())
160}
161
162/// Copies a segment of a video file without re-encoding.
163pub async fn extract(input: &str, output: &str, start: f64, duration: f64) -> anyhow::Result<()> {
164    let args = vec![
165        "-y".to_string(),
166        "-ss".into(),
167        format!("{start:.6}"),
168        "-i".into(),
169        input.into(),
170        "-t".into(),
171        format!("{duration:.6}"),
172        "-c".into(),
173        "copy".into(),
174        "-avoid_negative_ts".into(),
175        "make_zero".into(),
176        output.into(),
177    ];
178
179    let output = Command::new(ffmpeg_path())
180        .args(&args)
181        .stderr(std::process::Stdio::piped())
182        .output()
183        .await?;
184
185    if !output.status.success() {
186        let stderr = String::from_utf8_lossy(&output.stderr);
187        anyhow::bail!("ffmpeg extract failed: {stderr}");
188    }
189    Ok(())
190}
191
192/// Concatenates multiple encoded chunks into a single output without re-encoding.
193pub async fn concat(inputs: &[String], output: &str) -> anyhow::Result<()> {
194    if inputs.is_empty() {
195        anyhow::bail!("cannot concat an empty input list");
196    }
197
198    let list_path = make_concat_list_path(output);
199    let list_body = inputs
200        .iter()
201        .map(|path| format!("file '{}'", path.replace('\'', "'\\''")))
202        .collect::<Vec<_>>()
203        .join("\n");
204    std::fs::write(&list_path, format!("{list_body}\n"))?;
205
206    let args = vec![
207        "-y".to_string(),
208        "-f".into(),
209        "concat".into(),
210        "-safe".into(),
211        "0".into(),
212        "-i".into(),
213        list_path.to_string_lossy().into_owned(),
214        "-c".into(),
215        "copy".into(),
216        output.into(),
217    ];
218
219    let result = run_ffmpeg(args, None).await;
220    let _ = std::fs::remove_file(&list_path);
221    result
222}
223
224enum EncodePass<'a> {
225    Single,
226    First(&'a Path),
227    Second(&'a Path),
228}
229
230fn build_encode_args(job: &EncodeJob, pass: EncodePass<'_>) -> anyhow::Result<Vec<String>> {
231    let mut args = vec!["-y".into()];
232
233    // ── Input-level options (must precede -i) ──
234    // Hardware-accelerated decode. Without an explicit output format the decoded
235    // frames are downloaded back to system memory, so the rest of the software
236    // filter/encode pipeline keeps working unchanged.
237    if let Some(accel) = job.hwaccel.as_deref().filter(|a| !a.is_empty()) {
238        args.extend(["-hwaccel".into(), accel.into()]);
239    }
240    // VAAPI encoders require a render device initialised before the input so the
241    // `hwupload` filter has a target surface pool.
242    if job.codec.backend() == EncoderBackend::Vaapi {
243        args.extend(["-vaapi_device".into(), vaapi_device()]);
244    }
245
246    args.extend(["-i".into(), job.input.clone(), "-an".into()]);
247
248    if !matches!(pass, EncodePass::First(_)) {
249        args.extend(["-progress".into(), "pipe:1".into(), "-nostats".into()]);
250    }
251
252    args.extend(["-c:v".into(), job.codec.as_str().into()]);
253
254    if job.codec.is_hardware() {
255        build_hw_args(&mut args, job, &pass)?;
256    } else {
257        build_sw_args(&mut args, job, &pass)?;
258    }
259
260    if !job.preset.is_empty() {
261        if job.codec.is_hardware() {
262            add_hw_preset(&mut args, job.codec, &job.preset);
263        } else {
264            args.extend(["-preset".into(), job.preset.clone()]);
265        }
266    }
267
268    if let Some(vf) = build_filter_chain(job) {
269        args.extend(["-vf".into(), vf]);
270    }
271
272    args.extend(job.extra_args.iter().cloned());
273
274    match pass {
275        EncodePass::First(_) => {
276            args.extend(["-f".into(), "null".into()]);
277            args.push(null_output_path().into());
278        }
279        EncodePass::Single | EncodePass::Second(_) => args.push(job.output.clone()),
280    }
281
282    Ok(args)
283}
284
285/// VAAPI render node to initialise. Overridable via `VISER_VAAPI_DEVICE` for
286/// hosts where the primary render node is not `renderD128`.
287fn vaapi_device() -> String {
288    std::env::var("VISER_VAAPI_DEVICE").unwrap_or_else(|_| "/dev/dri/renderD128".to_string())
289}
290
291/// Builds the `-vf` filter-chain value, or `None` when no filtering is needed.
292///
293/// Software encoders only scale (lanczos) when a target resolution is set.
294/// VAAPI encoders additionally need the frames converted and uploaded to GPU
295/// surfaces (`format=nv12,hwupload`), since the encoder consumes VAAPI surfaces
296/// — without this the encode fails with a format-conversion error.
297fn build_filter_chain(job: &EncodeJob) -> Option<String> {
298    let scale = job
299        .resolution
300        .filter(|res| res.width > 0 && res.height > 0)
301        .map(|res| format!("scale={}:{}:flags=lanczos", res.width, res.height));
302
303    if job.codec.backend() == EncoderBackend::Vaapi {
304        // Software-scale (if requested) in system memory, then upload to a VAAPI
305        // surface for the encoder.
306        Some(match scale {
307            Some(s) => format!("{s},format=nv12,hwupload"),
308            None => "format=nv12,hwupload".to_string(),
309        })
310    } else {
311        scale
312    }
313}
314
315fn build_sw_args(
316    args: &mut Vec<String>,
317    job: &EncodeJob,
318    pass: &EncodePass<'_>,
319) -> anyhow::Result<()> {
320    match job.rate_control {
321        RateControlMode::Qp => {
322            if job.codec == Codec::SvtAv1 {
323                args.extend(["-qp".into(), job.crf.to_string()]);
324                args.extend(["-svtav1-params".into(), "enable-adaptive-quantization=0".into()]);
325            } else {
326                args.extend(["-qp".into(), job.crf.to_string()]);
327            }
328        }
329        RateControlMode::CappedCrf => {
330            if job.max_bitrate <= 0.0 {
331                anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
332            }
333            let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
334            args.extend(["-crf".into(), job.crf.to_string()]);
335            args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
336            args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
337        }
338        RateControlMode::Vbr => {
339            if job.target_bitrate <= 0.0 {
340                anyhow::bail!("target bitrate must be greater than zero for VBR mode");
341            }
342            args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
343            args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
344            args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
345
346            let passlog = match pass {
347                EncodePass::First(path) => {
348                    args.extend(["-pass".into(), "1".into()]);
349                    path
350                }
351                EncodePass::Second(path) => {
352                    args.extend(["-pass".into(), "2".into()]);
353                    path
354                }
355                EncodePass::Single => {
356                    anyhow::bail!("VBR mode requires a two-pass encode flow");
357                }
358            };
359            args.extend(["-passlogfile".into(), passlog.to_string_lossy().into_owned()]);
360        }
361        RateControlMode::Crf => {
362            args.extend(["-crf".into(), job.crf.to_string()]);
363        }
364    }
365    Ok(())
366}
367
368fn build_hw_args(
369    args: &mut Vec<String>,
370    job: &EncodeJob,
371    _pass: &EncodePass<'_>,
372) -> anyhow::Result<()> {
373    let backend = job.codec.backend();
374    match job.rate_control {
375        RateControlMode::Crf | RateControlMode::CappedCrf => {
376            match backend {
377                EncoderBackend::Nvenc => {
378                    let cq = crf_to_nvenc_cq(job.crf);
379                    args.extend(["-cq".into(), cq.to_string()]);
380                    args.extend(["-rc".into(), "constqp".into()]);
381                }
382                EncoderBackend::Qsv => {
383                    let gq = crf_to_qsv_quality(job.crf);
384                    args.extend(["-global_quality".into(), gq.to_string()]);
385                }
386                EncoderBackend::VideoToolbox => {
387                    let qual = crf_to_vt_quality(job.crf);
388                    args.extend(["-quality".into(), qual.to_string()]);
389                }
390                EncoderBackend::Vaapi => {
391                    let gq = crf_to_qsv_quality(job.crf);
392                    args.extend(["-global_quality".into(), gq.to_string()]);
393                }
394                EncoderBackend::Amf => {
395                    args.extend(["-qp_i".into(), job.crf.to_string()]);
396                    args.extend(["-qp_p".into(), (job.crf + 2).to_string()]);
397                    args.extend(["-usage".into(), "transcoding".into()]);
398                }
399                EncoderBackend::Software => unreachable!(),
400            }
401            // VBV / maxrate for capped mode
402            if let RateControlMode::CappedCrf = job.rate_control {
403                if job.max_bitrate <= 0.0 {
404                    anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
405                }
406                let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
407                args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
408                args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
409            }
410        }
411        RateControlMode::Qp => match backend {
412            EncoderBackend::VideoToolbox => {
413                anyhow::bail!("VideoToolbox does not support QP rate control mode");
414            }
415            _ => {
416                args.extend(["-qp".into(), job.crf.to_string()]);
417            }
418        },
419        RateControlMode::Vbr => {
420            if job.target_bitrate <= 0.0 {
421                anyhow::bail!("target bitrate must be greater than zero for VBR mode");
422            }
423            args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
424            args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
425            args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
426
427            if backend == EncoderBackend::Nvenc {
428                args.extend(["-rc".into(), "vbr_hq".into()]);
429            }
430        }
431    }
432    Ok(())
433}
434
435fn crf_to_nvenc_cq(crf: i32) -> i32 {
436    let cq = (crf * 51) / 63;
437    cq.clamp(1, 51)
438}
439
440fn crf_to_qsv_quality(crf: i32) -> i32 {
441    let gq = 100 - ((crf * 100) / 51);
442    gq.clamp(1, 100)
443}
444
445fn crf_to_vt_quality(crf: i32) -> f64 {
446    let q = 1.0 - (crf as f64 / 51.0);
447    q.clamp(0.0, 1.0)
448}
449
450fn add_hw_preset(args: &mut Vec<String>, codec: Codec, preset: &str) {
451    match codec.backend() {
452        EncoderBackend::Nvenc => {
453            let p = map_nvenc_preset(preset);
454            args.extend(["-preset".into(), p.into()]);
455        }
456        EncoderBackend::Qsv => {
457            args.extend(["-preset".into(), preset.to_string()]);
458        }
459        EncoderBackend::Vaapi => {
460            args.extend(["-compression_level".into(), map_vaapi_preset(preset).into()]);
461        }
462        EncoderBackend::Amf => {
463            args.extend(["-quality".into(), map_amf_quality(preset).into()]);
464        }
465        EncoderBackend::VideoToolbox => {
466            if preset == "ultrafast" || preset == "superfast" || preset == "veryfast" {
467                args.extend(["-realtime".into(), "1".into()]);
468            }
469        }
470        EncoderBackend::Software => unreachable!(),
471    }
472}
473
474fn map_nvenc_preset(preset: &str) -> &str {
475    match preset {
476        "ultrafast" | "superfast" => "p1",
477        "veryfast" => "p2",
478        "faster" => "p3",
479        "fast" => "p4",
480        "medium" => "p5",
481        "slow" => "p6",
482        "slower" | "veryslow" => "p7",
483        other => other,
484    }
485}
486
487fn map_vaapi_preset(preset: &str) -> &str {
488    match preset {
489        "ultrafast" | "superfast" => "1",
490        "veryfast" | "faster" => "2",
491        "fast" | "medium" => "3",
492        "slow" => "4",
493        "slower" | "veryslow" => "5",
494        other => other,
495    }
496}
497
498fn map_amf_quality(preset: &str) -> &str {
499    match preset {
500        "ultrafast" | "superfast" => "speed",
501        "veryfast" | "faster" | "fast" => "balanced",
502        "medium" | "slow" | "slower" | "veryslow" => "quality",
503        other => other,
504    }
505}
506
507fn make_passlog_prefix(output: &str) -> PathBuf {
508    let output_path = Path::new(output);
509    let parent =
510        output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
511    let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
512    let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
513    parent.join(format!(".{stem}.viser-passlog-{unique}-{}", std::process::id()))
514}
515
516fn make_concat_list_path(output: &str) -> PathBuf {
517    let output_path = Path::new(output);
518    let parent =
519        output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
520    let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
521    let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
522    parent.join(format!(".{stem}.viser-concat-{unique}-{}.txt", std::process::id()))
523}
524
525fn null_output_path() -> &'static str {
526    if cfg!(windows) { "NUL" } else { "/dev/null" }
527}
528
529struct PasslogCleanup {
530    parent: PathBuf,
531    prefix: String,
532}
533
534impl PasslogCleanup {
535    fn new(path: PathBuf) -> Self {
536        let parent = path.parent().unwrap_or(Path::new(".")).to_path_buf();
537        let prefix = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_owned();
538        Self { parent, prefix }
539    }
540
541    fn run(&self) {
542        let Ok(entries) = std::fs::read_dir(&self.parent) else {
543            return;
544        };
545
546        for entry in entries.flatten() {
547            let path = entry.path();
548            let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
549                continue;
550            };
551            if !name.starts_with(&self.prefix) {
552                continue;
553            }
554            if let Err(err) = std::fs::remove_file(&path) {
555                tracing::debug!(?path, ?err, "failed to remove ffmpeg two-pass log file");
556            }
557        }
558    }
559}
560
561/// Returns true when a complete progress block is ready.
562fn parse_progress_line(line: &str, p: &mut Progress) -> bool {
563    let Some((key, value)) = line.split_once('=') else {
564        return false;
565    };
566
567    match key {
568        "frame" => {
569            p.frame = value.parse().unwrap_or(0);
570        }
571        "fps" => {
572            p.fps = value.parse().unwrap_or(0.0);
573        }
574        "bitrate" => {
575            let v = value.trim_end_matches("kbits/s");
576            p.bitrate = v.parse().unwrap_or(0.0);
577        }
578        "speed" => {
579            let v = value.trim_end_matches('x');
580            p.speed = v.parse().unwrap_or(0.0);
581        }
582        "out_time_us" => {
583            let us: u64 = value.parse().unwrap_or(0);
584            p.time = Duration::from_micros(us);
585        }
586        "progress" => return true,
587        _ => {}
588    }
589    false
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use crate::Codec;
596
597    fn sample_job(mode: RateControlMode) -> EncodeJob {
598        EncodeJob {
599            input: "input.mp4".into(),
600            output: "output.mp4".into(),
601            resolution: Some(crate::Resolution::new(1280, 720)),
602            codec: Codec::X264,
603            crf: 23,
604            rate_control: mode,
605            target_bitrate: 2500.0,
606            max_bitrate: 3000.0,
607            bufsize: 6000.0,
608            preset: "medium".into(),
609            hwaccel: None,
610            extra_args: vec![],
611        }
612    }
613
614    fn job_with_codec(codec: Codec, mode: RateControlMode) -> EncodeJob {
615        EncodeJob { codec, rate_control: mode, ..sample_job(mode) }
616    }
617
618    // ── Helper: find adjacent argument pair ──
619    fn has_pair(args: &[String], a: &str, b: &str) -> bool {
620        args.windows(2).any(|w| w[0] == a && w[1] == b)
621    }
622
623    fn has_arg(args: &[String], a: &str) -> bool {
624        args.iter().any(|s| s == a)
625    }
626
627    // ── Software CRF ──
628    #[test]
629    fn test_build_encode_args_crf_single_pass() {
630        let args =
631            build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
632        assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
633        assert_eq!(args.last().unwrap(), "output.mp4");
634    }
635
636    #[test]
637    fn test_x264_crf_args() {
638        let args = build_encode_args(
639            &job_with_codec(Codec::X264, RateControlMode::Crf),
640            EncodePass::Single,
641        )
642        .unwrap();
643        assert!(has_pair(&args, "-c:v", "libx264"));
644        assert!(has_pair(&args, "-crf", "23"));
645        assert!(has_pair(&args, "-preset", "medium"));
646    }
647
648    #[test]
649    fn test_x265_crf_args() {
650        let args = build_encode_args(
651            &job_with_codec(Codec::X265, RateControlMode::Crf),
652            EncodePass::Single,
653        )
654        .unwrap();
655        assert!(has_pair(&args, "-c:v", "libx265"));
656        assert!(has_pair(&args, "-crf", "23"));
657    }
658
659    #[test]
660    fn test_svtav1_crf_args() {
661        let args = build_encode_args(
662            &job_with_codec(Codec::SvtAv1, RateControlMode::Crf),
663            EncodePass::Single,
664        )
665        .unwrap();
666        assert!(has_pair(&args, "-c:v", "libsvtav1"));
667        assert!(has_pair(&args, "-crf", "23"));
668    }
669
670    // ── Software QP ──
671    #[test]
672    fn test_x264_qp_args() {
673        let args = build_encode_args(
674            &job_with_codec(Codec::X264, RateControlMode::Qp),
675            EncodePass::Single,
676        )
677        .unwrap();
678        assert!(has_pair(&args, "-qp", "23"));
679        assert!(!has_arg(&args, "-crf"));
680    }
681
682    #[test]
683    fn test_x265_qp_args() {
684        let args = build_encode_args(
685            &job_with_codec(Codec::X265, RateControlMode::Qp),
686            EncodePass::Single,
687        )
688        .unwrap();
689        assert!(has_pair(&args, "-qp", "23"));
690    }
691
692    #[test]
693    fn test_svtav1_qp_adds_adaptive_quantization_off() {
694        let args = build_encode_args(
695            &job_with_codec(Codec::SvtAv1, RateControlMode::Qp),
696            EncodePass::Single,
697        )
698        .unwrap();
699        assert!(has_pair(&args, "-qp", "23"));
700        assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"));
701    }
702
703    // ── Software Capped CRF ──
704    #[test]
705    fn test_build_encode_args_capped_crf_sets_vbv() {
706        let args =
707            build_encode_args(&sample_job(RateControlMode::CappedCrf), EncodePass::Single).unwrap();
708        assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
709        assert!(args.windows(2).any(|w| w == ["-maxrate", "3000k"]));
710        assert!(args.windows(2).any(|w| w == ["-bufsize", "6000k"]));
711    }
712
713    #[test]
714    fn test_capped_crf_max_bitrate_zero_errors() {
715        let job = EncodeJob {
716            max_bitrate: 0.0,
717            rate_control: RateControlMode::CappedCrf,
718            ..sample_job(RateControlMode::CappedCrf)
719        };
720        assert!(build_encode_args(&job, EncodePass::Single).is_err());
721    }
722
723    #[test]
724    fn test_capped_crf_max_bitrate_negative_errors() {
725        let job = EncodeJob {
726            max_bitrate: -1.0,
727            rate_control: RateControlMode::CappedCrf,
728            ..sample_job(RateControlMode::CappedCrf)
729        };
730        assert!(build_encode_args(&job, EncodePass::Single).is_err());
731    }
732
733    #[test]
734    fn test_capped_crf_auto_bufsize_when_zero() {
735        let job = EncodeJob {
736            max_bitrate: 4000.0,
737            bufsize: 0.0,
738            rate_control: RateControlMode::CappedCrf,
739            ..sample_job(RateControlMode::CappedCrf)
740        };
741        let args = build_encode_args(&job, EncodePass::Single).unwrap();
742        assert!(has_pair(&args, "-bufsize", "8000k"));
743    }
744
745    // ── Software VBR ──
746    #[test]
747    fn test_vbr_target_bitrate_zero_errors() {
748        let job = EncodeJob {
749            target_bitrate: 0.0,
750            rate_control: RateControlMode::Vbr,
751            ..sample_job(RateControlMode::Vbr)
752        };
753        assert!(build_encode_args(&job, EncodePass::First(Path::new("passlog"))).is_err());
754    }
755
756    #[test]
757    fn test_vbr_single_pass_errors() {
758        assert!(build_encode_args(&sample_job(RateControlMode::Vbr), EncodePass::Single).is_err());
759    }
760
761    #[test]
762    fn test_build_encode_args_vbr_first_pass_uses_null_output() {
763        let job = sample_job(RateControlMode::Vbr);
764        let passlog = Path::new("/tmp/viser-passlog");
765        let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
766        assert!(args.windows(2).any(|w| w == ["-pass", "1"]));
767        assert!(args.windows(2).any(|w| w == ["-f", "null"]));
768        assert_eq!(args.last().unwrap(), null_output_path());
769    }
770
771    #[test]
772    fn test_build_encode_args_vbr_second_pass_writes_output() {
773        let job = sample_job(RateControlMode::Vbr);
774        let passlog = Path::new("/tmp/viser-passlog");
775        let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
776        assert!(args.windows(2).any(|w| w == ["-pass", "2"]));
777        assert_eq!(args.last().unwrap(), "output.mp4");
778    }
779
780    #[test]
781    fn test_vbr_first_pass_no_progress_args() {
782        let job = sample_job(RateControlMode::Vbr);
783        let passlog = Path::new("/tmp/viser-passlog");
784        let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
785        assert!(!has_arg(&args, "-progress"));
786        assert!(!has_arg(&args, "-nostats"));
787    }
788
789    #[test]
790    fn test_vbr_second_pass_sets_bitrate_and_vbv() {
791        let job = sample_job(RateControlMode::Vbr);
792        let passlog = Path::new("/tmp/viser-passlog");
793        let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
794        assert!(has_pair(&args, "-b:v", "2500k"));
795        assert!(has_pair(&args, "-maxrate", "5000k"));
796        assert!(has_pair(&args, "-bufsize", "10000k"));
797    }
798
799    #[test]
800    fn test_vbr_sets_passlog() {
801        let job = sample_job(RateControlMode::Vbr);
802        let passlog = Path::new("/tmp/viser-passlog");
803        let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
804        assert!(has_arg(&args, "/tmp/viser-passlog"));
805    }
806
807    // ── Resolution scaling ──
808    #[test]
809    fn test_resolution_scaling_adds_vf() {
810        let args =
811            build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
812        assert!(has_arg(&args, "-vf"));
813        assert!(has_arg(&args, "scale=1280:720:flags=lanczos"));
814    }
815
816    #[test]
817    fn test_zero_width_skips_scale() {
818        let job = EncodeJob {
819            resolution: Some(crate::Resolution::new(0, 720)),
820            ..sample_job(RateControlMode::Crf)
821        };
822        let args = build_encode_args(&job, EncodePass::Single).unwrap();
823        assert!(!has_arg(&args, "-vf"));
824    }
825
826    #[test]
827    fn test_zero_height_skips_scale() {
828        let job = EncodeJob {
829            resolution: Some(crate::Resolution::new(1280, 0)),
830            ..sample_job(RateControlMode::Crf)
831        };
832        let args = build_encode_args(&job, EncodePass::Single).unwrap();
833        assert!(!has_arg(&args, "-vf"));
834    }
835
836    #[test]
837    fn test_no_resolution_skips_scale() {
838        let job = EncodeJob { resolution: None, ..sample_job(RateControlMode::Crf) };
839        let args = build_encode_args(&job, EncodePass::Single).unwrap();
840        assert!(!has_arg(&args, "-vf"));
841    }
842
843    #[test]
844    fn test_resolution_negative_skip_scale() {
845        let job = EncodeJob {
846            resolution: Some(crate::Resolution::new(-1, -1)),
847            ..sample_job(RateControlMode::Crf)
848        };
849        let args = build_encode_args(&job, EncodePass::Single).unwrap();
850        assert!(!has_arg(&args, "-vf"));
851    }
852
853    // ── Preset handling ──
854    #[test]
855    fn test_empty_preset_no_preset_arg() {
856        let job = EncodeJob { preset: String::new(), ..sample_job(RateControlMode::Crf) };
857        let args = build_encode_args(&job, EncodePass::Single).unwrap();
858        assert!(!has_arg(&args, "-preset"));
859    }
860
861    #[test]
862    fn test_preset_with_x264() {
863        let job = EncodeJob {
864            codec: Codec::X264,
865            preset: "fast".into(),
866            ..sample_job(RateControlMode::Crf)
867        };
868        let args = build_encode_args(&job, EncodePass::Single).unwrap();
869        assert!(has_pair(&args, "-preset", "fast"));
870    }
871
872    // ── Extra args ──
873    #[test]
874    fn test_extra_args_appended_before_output() {
875        let job = EncodeJob {
876            extra_args: vec!["-g".into(), "30".into(), "-bf".into(), "2".into()],
877            ..sample_job(RateControlMode::Crf)
878        };
879        let args = build_encode_args(&job, EncodePass::Single).unwrap();
880        assert!(has_pair(&args, "-g", "30"));
881        assert!(has_pair(&args, "-bf", "2"));
882        assert_eq!(args.last().unwrap(), "output.mp4");
883    }
884
885    // ── Null output path ──
886    #[test]
887    fn test_null_output_path_is_platform_appropriate() {
888        let null = null_output_path();
889        assert!(!null.is_empty());
890        assert!(null == "/dev/null" || null == "NUL");
891    }
892
893    // ── Progress parsing ──
894    #[test]
895    fn test_parse_progress_line_frame() {
896        let mut p = Progress::default();
897        assert!(!parse_progress_line("frame=100", &mut p));
898        assert_eq!(p.frame, 100);
899    }
900
901    #[test]
902    fn test_parse_progress_line_fps() {
903        let mut p = Progress::default();
904        parse_progress_line("fps=23.976", &mut p);
905        assert!((p.fps - 23.976).abs() < 0.001);
906    }
907
908    #[test]
909    fn test_parse_progress_line_bitrate() {
910        let mut p = Progress::default();
911        parse_progress_line("bitrate=1500.5kbits/s", &mut p);
912        assert!((p.bitrate - 1500.5).abs() < 0.001);
913    }
914
915    #[test]
916    fn test_parse_progress_line_speed() {
917        let mut p = Progress::default();
918        parse_progress_line("speed=1.5x", &mut p);
919        assert!((p.speed - 1.5).abs() < 0.001);
920    }
921
922    #[test]
923    fn test_parse_progress_line_out_time_us() {
924        let mut p = Progress::default();
925        parse_progress_line("out_time_us=1234567", &mut p);
926        assert_eq!(p.time, Duration::from_micros(1234567));
927    }
928
929    #[test]
930    fn test_parse_progress_returns_true_on_progress() {
931        let mut p = Progress::default();
932        assert!(parse_progress_line("progress=continue", &mut p));
933    }
934
935    #[test]
936    fn test_parse_progress_full_block() {
937        let mut p = Progress::default();
938        parse_progress_line("frame=1500", &mut p);
939        parse_progress_line("fps=25.0", &mut p);
940        parse_progress_line("bitrate=2000.0kbits/s", &mut p);
941        parse_progress_line("speed=2.0x", &mut p);
942        parse_progress_line("out_time_us=60000000", &mut p);
943        assert!(parse_progress_line("progress=continue", &mut p));
944        assert_eq!(p.frame, 1500);
945        assert_eq!(p.time, Duration::from_secs(60));
946    }
947
948    #[test]
949    fn test_parse_progress_line_missing_equals() {
950        let mut p = Progress::default();
951        assert!(!parse_progress_line("noequals", &mut p));
952    }
953
954    #[test]
955    fn test_parse_progress_line_unknown_key() {
956        let mut p = Progress::default();
957        assert!(!parse_progress_line("unknown=42", &mut p));
958    }
959
960    #[test]
961    fn test_parse_progress_line_bogus_numbers() {
962        let mut p = Progress::default();
963        parse_progress_line("frame=abc", &mut p);
964        assert_eq!(p.frame, 0);
965    }
966
967    // ── Make passlog prefix ──
968    #[test]
969    fn test_make_passlog_prefix_uses_output_dir() {
970        let prefix = make_passlog_prefix("/path/to/video.mp4");
971        assert!(prefix.starts_with(Path::new("/path/to")));
972        assert!(prefix.to_string_lossy().contains("video"));
973    }
974
975    #[test]
976    fn test_make_passlog_prefix_no_parent_falls_back_to_cwd() {
977        let prefix = make_passlog_prefix("video.mp4");
978        assert!(prefix.starts_with(Path::new(".")));
979    }
980
981    // ── Make concat list path ──
982    #[test]
983    fn test_make_concat_list_path_is_txt() {
984        let path = make_concat_list_path("output.mp4");
985        assert!(path.to_string_lossy().ends_with(".txt"));
986    }
987
988    // ── Helper: hardware-specific job builders ──
989    fn hw_crf(codec: Codec) -> EncodeJob {
990        EncodeJob {
991            codec,
992            preset: String::new(),
993            resolution: None,
994            extra_args: vec![],
995            ..sample_job(RateControlMode::Crf)
996        }
997    }
998
999    fn hw_qp(codec: Codec) -> EncodeJob {
1000        EncodeJob {
1001            codec,
1002            preset: String::new(),
1003            resolution: None,
1004            extra_args: vec![],
1005            ..sample_job(RateControlMode::Qp)
1006        }
1007    }
1008
1009    // ── Hardware encoder CRF (quality-based constant mode) ──
1010    #[test]
1011    fn test_nvenc_h264_crf_uses_constqp() {
1012        let args = build_encode_args(&hw_crf(Codec::NvencH264), EncodePass::Single).unwrap();
1013        assert!(has_pair(&args, "-rc", "constqp"));
1014        assert!(has_arg(&args, "-cq"));
1015    }
1016
1017    #[test]
1018    fn test_nvenc_h265_crf_uses_constqp() {
1019        let args = build_encode_args(&hw_crf(Codec::NvencH265), EncodePass::Single).unwrap();
1020        assert!(has_pair(&args, "-rc", "constqp"));
1021        assert!(has_arg(&args, "-cq"));
1022    }
1023
1024    #[test]
1025    fn test_qsv_h264_crf_uses_global_quality() {
1026        let args = build_encode_args(&hw_crf(Codec::QsvH264), EncodePass::Single).unwrap();
1027        assert!(has_arg(&args, "-global_quality"));
1028    }
1029
1030    #[test]
1031    fn test_qsv_h265_crf_uses_global_quality() {
1032        let args = build_encode_args(&hw_crf(Codec::QsvH265), EncodePass::Single).unwrap();
1033        assert!(has_arg(&args, "-global_quality"));
1034    }
1035
1036    #[test]
1037    fn test_vt_h264_crf_uses_quality() {
1038        let args = build_encode_args(&hw_crf(Codec::VideoToolboxH264), EncodePass::Single).unwrap();
1039        assert!(has_arg(&args, "-quality"));
1040    }
1041
1042    #[test]
1043    fn test_vt_h265_crf_uses_quality() {
1044        let args = build_encode_args(&hw_crf(Codec::VideoToolboxH265), EncodePass::Single).unwrap();
1045        assert!(has_arg(&args, "-quality"));
1046    }
1047
1048    #[test]
1049    fn test_vaapi_h264_crf_uses_global_quality() {
1050        let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1051        assert!(has_arg(&args, "-global_quality"));
1052    }
1053
1054    #[test]
1055    fn test_vaapi_h265_crf_uses_global_quality() {
1056        let args = build_encode_args(&hw_crf(Codec::VaapiH265), EncodePass::Single).unwrap();
1057        assert!(has_arg(&args, "-global_quality"));
1058    }
1059
1060    #[test]
1061    fn test_amf_h264_crf_uses_qp_and_usage() {
1062        let args = build_encode_args(&hw_crf(Codec::AmfH264), EncodePass::Single).unwrap();
1063        assert!(has_pair(&args, "-qp_i", "23"));
1064        assert!(has_pair(&args, "-qp_p", "25"));
1065        assert!(has_pair(&args, "-usage", "transcoding"));
1066    }
1067
1068    #[test]
1069    fn test_amf_h265_crf_uses_qp_and_usage() {
1070        let args = build_encode_args(&hw_crf(Codec::AmfH265), EncodePass::Single).unwrap();
1071        assert!(has_pair(&args, "-qp_i", "23"));
1072        assert!(has_pair(&args, "-qp_p", "25"));
1073        assert!(has_pair(&args, "-usage", "transcoding"));
1074    }
1075
1076    // ── Hardware encoder QP ──
1077    #[test]
1078    fn test_nvenc_h264_qp() {
1079        let args = build_encode_args(&hw_qp(Codec::NvencH264), EncodePass::Single).unwrap();
1080        assert!(has_pair(&args, "-qp", "23"));
1081    }
1082
1083    #[test]
1084    fn test_qsv_h264_qp() {
1085        let args = build_encode_args(&hw_qp(Codec::QsvH264), EncodePass::Single).unwrap();
1086        assert!(has_pair(&args, "-qp", "23"));
1087    }
1088
1089    #[test]
1090    fn test_vaapi_h264_qp() {
1091        let args = build_encode_args(&hw_qp(Codec::VaapiH264), EncodePass::Single).unwrap();
1092        assert!(has_pair(&args, "-qp", "23"));
1093    }
1094
1095    #[test]
1096    fn test_amf_h264_qp() {
1097        let args = build_encode_args(&hw_qp(Codec::AmfH264), EncodePass::Single).unwrap();
1098        assert!(has_pair(&args, "-qp", "23"));
1099    }
1100
1101    #[test]
1102    fn test_vt_qp_rejected() {
1103        let result = build_encode_args(&hw_qp(Codec::VideoToolboxH264), EncodePass::Single);
1104        assert!(result.is_err());
1105    }
1106
1107    #[test]
1108    fn test_vt_h265_qp_rejected() {
1109        let result = build_encode_args(&hw_qp(Codec::VideoToolboxH265), EncodePass::Single);
1110        assert!(result.is_err());
1111    }
1112
1113    // ── Hardware encoder capped CRF ──
1114    #[test]
1115    fn test_nvenc_capped_crf_sets_vbv() {
1116        let job = EncodeJob {
1117            codec: Codec::NvencH264,
1118            max_bitrate: 5000.0,
1119            bufsize: 10000.0,
1120            rate_control: RateControlMode::CappedCrf,
1121            ..sample_job(RateControlMode::Crf)
1122        };
1123        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1124        assert!(has_pair(&args, "-rc", "constqp"));
1125        assert!(has_pair(&args, "-maxrate", "5000k"));
1126        assert!(has_pair(&args, "-bufsize", "10000k"));
1127    }
1128
1129    #[test]
1130    fn test_hw_capped_crf_max_bitrate_zero_errors() {
1131        let job = EncodeJob {
1132            codec: Codec::NvencH264,
1133            max_bitrate: 0.0,
1134            rate_control: RateControlMode::CappedCrf,
1135            ..sample_job(RateControlMode::Crf)
1136        };
1137        assert!(build_encode_args(&job, EncodePass::Single).is_err());
1138    }
1139
1140    // ── Hardware encoder VBR ──
1141    #[test]
1142    fn test_nvenc_vbr_uses_vbr_hq() {
1143        let job = EncodeJob {
1144            codec: Codec::NvencH264,
1145            target_bitrate: 5000.0,
1146            rate_control: RateControlMode::Vbr,
1147            ..sample_job(RateControlMode::Vbr)
1148        };
1149        let passlog = Path::new("/tmp/plog");
1150        let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1151        assert!(has_pair(&args, "-rc", "vbr_hq"));
1152    }
1153
1154    #[test]
1155    fn test_qsv_vbr_no_special_rc() {
1156        let job = EncodeJob {
1157            codec: Codec::QsvH264,
1158            target_bitrate: 5000.0,
1159            rate_control: RateControlMode::Vbr,
1160            ..sample_job(RateControlMode::Vbr)
1161        };
1162        let passlog = Path::new("/tmp/plog");
1163        let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1164        assert!(!has_arg(&args, "-rc"));
1165    }
1166
1167    #[test]
1168    fn test_hw_vbr_target_bitrate_zero_errors() {
1169        let job = EncodeJob {
1170            codec: Codec::NvencH264,
1171            target_bitrate: 0.0,
1172            rate_control: RateControlMode::Vbr,
1173            ..sample_job(RateControlMode::Vbr)
1174        };
1175        let passlog = Path::new("/tmp/plog");
1176        assert!(build_encode_args(&job, EncodePass::Second(passlog)).is_err());
1177    }
1178
1179    // ── Hardware preset mappings ──
1180    #[test]
1181    fn test_nvenc_preset_maps_to_p_numbers() {
1182        let job = EncodeJob {
1183            codec: Codec::NvencH264,
1184            preset: "veryfast".into(),
1185            ..sample_job(RateControlMode::Crf)
1186        };
1187        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1188        assert!(has_pair(&args, "-preset", "p2"));
1189    }
1190
1191    #[test]
1192    fn test_vaapi_preset_uses_compression_level() {
1193        let job = EncodeJob {
1194            codec: Codec::VaapiH264,
1195            preset: "medium".into(),
1196            ..sample_job(RateControlMode::Crf)
1197        };
1198        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1199        assert!(has_pair(&args, "-compression_level", "3"));
1200    }
1201
1202    #[test]
1203    fn test_amf_preset_uses_quality() {
1204        let job = EncodeJob {
1205            codec: Codec::AmfH264,
1206            preset: "slow".into(),
1207            ..sample_job(RateControlMode::Crf)
1208        };
1209        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1210        assert!(has_pair(&args, "-quality", "quality"));
1211    }
1212
1213    #[test]
1214    fn test_amf_preset_speed() {
1215        let job = EncodeJob {
1216            codec: Codec::AmfH264,
1217            preset: "ultrafast".into(),
1218            ..sample_job(RateControlMode::Crf)
1219        };
1220        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1221        assert!(has_pair(&args, "-quality", "speed"));
1222    }
1223
1224    // ── AV1 hardware encoders ──
1225    #[test]
1226    fn test_av1_hw_codecs_have_correct_codec_string() {
1227        for codec in &[Codec::NvencAv1, Codec::QsvAv1, Codec::VaapiAv1, Codec::AmfAv1] {
1228            let job = EncodeJob {
1229                codec: *codec,
1230                preset: String::new(),
1231                resolution: None,
1232                extra_args: vec![],
1233                ..sample_job(RateControlMode::Crf)
1234            };
1235            let args = build_encode_args(&job, EncodePass::Single).unwrap();
1236            assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1237        }
1238    }
1239
1240    #[test]
1241    fn test_av1_nvenc_crf_uses_constqp() {
1242        let args = build_encode_args(&hw_crf(Codec::NvencAv1), EncodePass::Single).unwrap();
1243        assert!(has_pair(&args, "-rc", "constqp"));
1244        assert!(has_arg(&args, "-cq"));
1245    }
1246
1247    #[test]
1248    fn test_av1_vaapi_crf_uses_global_quality() {
1249        let args = build_encode_args(&hw_crf(Codec::VaapiAv1), EncodePass::Single).unwrap();
1250        assert!(has_arg(&args, "-global_quality"));
1251    }
1252
1253    // ── VAAPI device init + hwupload filter chain ──
1254    #[test]
1255    fn test_vaapi_sets_device_before_input() {
1256        let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1257        let dev_idx =
1258            args.iter().position(|a| a == "-vaapi_device").expect("missing -vaapi_device");
1259        let i_idx = args.iter().position(|a| a == "-i").expect("missing -i");
1260        assert!(dev_idx < i_idx, "-vaapi_device must precede -i: {args:?}");
1261    }
1262
1263    #[test]
1264    fn test_vaapi_filter_chain_has_hwupload() {
1265        // With a target resolution: scale then format+upload, in one -vf chain.
1266        let job = EncodeJob { codec: Codec::VaapiH264, ..sample_job(RateControlMode::Crf) };
1267        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1268        let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1269        let vf = &args[vf_idx + 1];
1270        assert!(vf.contains("scale=1280:720:flags=lanczos"), "missing scale: {vf}");
1271        assert!(vf.contains("format=nv12,hwupload"), "missing hwupload: {vf}");
1272        assert_eq!(args.iter().filter(|a| *a == "-vf").count(), 1, "exactly one -vf: {args:?}");
1273    }
1274
1275    #[test]
1276    fn test_vaapi_hwupload_present_without_resolution() {
1277        let job = EncodeJob {
1278            codec: Codec::VaapiH264,
1279            resolution: None,
1280            ..sample_job(RateControlMode::Crf)
1281        };
1282        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1283        let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1284        assert_eq!(args[vf_idx + 1], "format=nv12,hwupload");
1285    }
1286
1287    #[test]
1288    fn test_non_vaapi_has_no_hwupload_or_device() {
1289        let job = EncodeJob { codec: Codec::NvencH264, ..sample_job(RateControlMode::Crf) };
1290        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1291        assert!(!has_arg(&args, "-vaapi_device"));
1292        let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1293        assert_eq!(args[vf_idx + 1], "scale=1280:720:flags=lanczos");
1294    }
1295
1296    // ── Hardware decode (hwaccel) ──
1297    #[test]
1298    fn test_hwaccel_injected_before_input() {
1299        let job = EncodeJob {
1300            codec: Codec::X264,
1301            hwaccel: Some("cuda".into()),
1302            ..sample_job(RateControlMode::Crf)
1303        };
1304        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1305        let acc_idx = args.iter().position(|a| a == "-hwaccel").expect("missing -hwaccel");
1306        let i_idx = args.iter().position(|a| a == "-i").expect("missing -i");
1307        assert_eq!(args[acc_idx + 1], "cuda");
1308        assert!(acc_idx < i_idx, "-hwaccel must precede -i: {args:?}");
1309    }
1310
1311    #[test]
1312    fn test_no_hwaccel_when_unset_or_empty() {
1313        for hw in [None, Some(String::new())] {
1314            let job =
1315                EncodeJob { codec: Codec::X264, hwaccel: hw, ..sample_job(RateControlMode::Crf) };
1316            let args = build_encode_args(&job, EncodePass::Single).unwrap();
1317            assert!(!has_arg(&args, "-hwaccel"), "unexpected -hwaccel: {args:?}");
1318        }
1319    }
1320
1321    #[test]
1322    fn test_amf_preset_balanced() {
1323        let job = EncodeJob {
1324            codec: Codec::AmfH264,
1325            preset: "fast".into(),
1326            ..sample_job(RateControlMode::Crf)
1327        };
1328        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1329        assert!(has_pair(&args, "-quality", "balanced"));
1330    }
1331
1332    #[test]
1333    fn test_vt_preset_realtime_for_ultrafast() {
1334        let job = EncodeJob {
1335            codec: Codec::VideoToolboxH264,
1336            preset: "ultrafast".into(),
1337            ..sample_job(RateControlMode::Crf)
1338        };
1339        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1340        assert!(has_pair(&args, "-realtime", "1"));
1341    }
1342
1343    #[test]
1344    fn test_vt_preset_realtime_for_veryfast() {
1345        let job = EncodeJob {
1346            codec: Codec::VideoToolboxH264,
1347            preset: "veryfast".into(),
1348            ..sample_job(RateControlMode::Crf)
1349        };
1350        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1351        assert!(has_pair(&args, "-realtime", "1"));
1352    }
1353
1354    #[test]
1355    fn test_vt_preset_no_realtime_for_slow() {
1356        let job = EncodeJob {
1357            codec: Codec::VideoToolboxH264,
1358            preset: "slow".into(),
1359            ..sample_job(RateControlMode::Crf)
1360        };
1361        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1362        assert!(!has_arg(&args, "-realtime"));
1363    }
1364
1365    #[test]
1366    fn test_qsv_preset_passthrough() {
1367        let job = EncodeJob {
1368            codec: Codec::QsvH264,
1369            preset: "medium".into(),
1370            ..sample_job(RateControlMode::Crf)
1371        };
1372        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1373        assert!(has_pair(&args, "-preset", "medium"));
1374    }
1375
1376    // ── CRF-to-HW quality conversion ──
1377    #[test]
1378    fn test_crf_to_nvenc_cq_bounds() {
1379        assert_eq!(crf_to_nvenc_cq(0), 1); // clamped to 1
1380        assert_eq!(crf_to_nvenc_cq(51), 41); // (51*51)/63 ≈ 41
1381        assert_eq!(crf_to_nvenc_cq(63), 51); // (63*51)/63 = 51
1382        assert_eq!(crf_to_nvenc_cq(100), 51); // clamped to 51
1383    }
1384
1385    #[test]
1386    fn test_crf_to_nvenc_cq_typical_values() {
1387        assert_eq!(crf_to_nvenc_cq(23), 18); // (23*51)/63 ≈ 18.6 → 18
1388        assert_eq!(crf_to_nvenc_cq(30), 24); // (30*51)/63 ≈ 24.2 → 24
1389    }
1390
1391    #[test]
1392    fn test_crf_to_qsv_quality_bounds() {
1393        let q0 = crf_to_qsv_quality(0);
1394        assert!((95..=100).contains(&q0)); // 100 - (0*100)/51 = 100
1395        let q51 = crf_to_qsv_quality(51);
1396        assert_eq!(q51, 1); // clamped to 1
1397        let q100 = crf_to_qsv_quality(100);
1398        assert_eq!(q100, 1); // clamped at bottom
1399    }
1400
1401    #[test]
1402    fn test_crf_to_qsv_quality_mid() {
1403        let q = crf_to_qsv_quality(25);
1404        // 100 - (25*100)/51 ≈ 100 - 49 = 51
1405        assert!((50..=52).contains(&q));
1406    }
1407
1408    #[test]
1409    fn test_crf_to_vt_quality_bounds() {
1410        assert!((crf_to_vt_quality(0) - 1.0).abs() < 1e-9);
1411        assert!((crf_to_vt_quality(51) - 0.0).abs() < 1e-9);
1412        assert!((crf_to_vt_quality(100) - 0.0).abs() < 1e-9);
1413    }
1414
1415    #[test]
1416    fn test_crf_to_vt_quality_mid() {
1417        let q = crf_to_vt_quality(25);
1418        assert!(q > 0.4 && q < 0.6);
1419    }
1420
1421    // ── Specific CRF value edge cases ──
1422    #[test]
1423    fn test_crf_zero() {
1424        let job = EncodeJob { crf: 0, ..sample_job(RateControlMode::Crf) };
1425        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1426        assert!(has_pair(&args, "-crf", "0"));
1427    }
1428
1429    #[test]
1430    fn test_crf_high_value() {
1431        let job = EncodeJob { crf: 51, ..sample_job(RateControlMode::Crf) };
1432        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1433        assert!(has_pair(&args, "-crf", "51"));
1434    }
1435
1436    #[test]
1437    fn test_crf_negative_allowed() {
1438        let job = EncodeJob { crf: -1, ..sample_job(RateControlMode::Crf) };
1439        let args = build_encode_args(&job, EncodePass::Single).unwrap();
1440        assert!(has_pair(&args, "-crf", "-1"));
1441    }
1442
1443    #[test]
1444    fn test_input_arg_is_present() {
1445        let args =
1446            build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1447        assert!(has_pair(&args, "-i", "input.mp4"));
1448    }
1449
1450    #[test]
1451    fn test_no_audio_flag_is_present() {
1452        let args =
1453            build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1454        assert!(has_arg(&args, "-an"));
1455    }
1456
1457    // ── All software codecs + modes use correct codec string ──
1458    #[test]
1459    fn test_all_sw_codecs_have_correct_codec_string() {
1460        for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
1461            let job = EncodeJob {
1462                codec: *codec,
1463                preset: String::new(),
1464                resolution: None,
1465                extra_args: vec![],
1466                ..sample_job(RateControlMode::Crf)
1467            };
1468            let args = build_encode_args(&job, EncodePass::Single).unwrap();
1469            assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1470        }
1471    }
1472
1473    #[test]
1474    fn test_all_hw_codecs_have_correct_codec_string() {
1475        for codec in &[
1476            Codec::NvencH264,
1477            Codec::NvencH265,
1478            Codec::QsvH264,
1479            Codec::QsvH265,
1480            Codec::VideoToolboxH264,
1481            Codec::VideoToolboxH265,
1482            Codec::VaapiH264,
1483            Codec::VaapiH265,
1484            Codec::AmfH264,
1485            Codec::AmfH265,
1486        ] {
1487            let job = EncodeJob {
1488                codec: *codec,
1489                preset: String::new(),
1490                resolution: None,
1491                extra_args: vec![],
1492                ..sample_job(RateControlMode::Crf)
1493            };
1494            let args = build_encode_args(&job, EncodePass::Single).unwrap();
1495            assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1496        }
1497    }
1498
1499    // ── Property-based: verify against FFmpeg encoder documentation ──
1500    #[cfg(test)]
1501    mod proptests {
1502        use super::*;
1503        use proptest::prelude::*;
1504
1505        fn arb_codec() -> impl Strategy<Value = Codec> {
1506            prop_oneof![
1507                Just(Codec::X264),
1508                Just(Codec::X265),
1509                Just(Codec::SvtAv1),
1510                Just(Codec::NvencH264),
1511                Just(Codec::NvencH265),
1512                Just(Codec::QsvH264),
1513                Just(Codec::QsvH265),
1514                Just(Codec::VideoToolboxH264),
1515                Just(Codec::VideoToolboxH265),
1516                Just(Codec::VaapiH264),
1517                Just(Codec::VaapiH265),
1518                Just(Codec::AmfH264),
1519                Just(Codec::AmfH265),
1520                Just(Codec::NvencAv1),
1521                Just(Codec::QsvAv1),
1522                Just(Codec::VaapiAv1),
1523                Just(Codec::AmfAv1),
1524            ]
1525        }
1526
1527        fn arb_rate_control() -> impl Strategy<Value = RateControlMode> {
1528            prop_oneof![
1529                Just(RateControlMode::Crf),
1530                Just(RateControlMode::Qp),
1531                Just(RateControlMode::CappedCrf),
1532            ]
1533        }
1534
1535        fn arb_encode_job() -> impl Strategy<Value = EncodeJob> {
1536            (
1537                arb_codec(),
1538                arb_rate_control(),
1539                any::<i32>(),
1540                any::<f64>(),
1541                any::<f64>(),
1542                any::<f64>(),
1543                any::<String>(),
1544            )
1545                .prop_map(|(codec, rc, crf, target_br, max_br, bufsize, preset)| {
1546                    let crf = crf.abs().min(63);
1547                    EncodeJob {
1548                        input: "input.mp4".into(),
1549                        output: "output.mp4".into(),
1550                        resolution: Some(Resolution::new(1920, 1080)),
1551                        codec,
1552                        crf,
1553                        rate_control: rc,
1554                        target_bitrate: target_br.abs().min(100000.0),
1555                        max_bitrate: max_br.abs().min(100000.0),
1556                        bufsize: bufsize.abs().min(200000.0),
1557                        preset,
1558                        hwaccel: None,
1559                        extra_args: vec![],
1560                    }
1561                })
1562        }
1563
1564        proptest! {
1565            /// Invariant: every arg list starts with -y, and the input file is
1566            /// named immediately after a `-i` flag. (Input-level options such as
1567            /// `-hwaccel` or `-vaapi_device` may sit between `-y` and `-i`.)
1568            #[test]
1569            fn args_start_with_overwrite_and_input(job in arb_encode_job()) {
1570                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1571                    assert!(args.len() >= 3, "too few args: {args:?}");
1572                    assert_eq!(args[0], "-y", "first arg must be -y");
1573                    let i_idx = args.iter().position(|a| a == "-i").expect("must contain -i");
1574                    assert_eq!(args[i_idx + 1], "input.mp4", "input path must follow -i");
1575                }
1576            }
1577
1578            /// Invariant: -an (no audio) present in single pass.
1579            #[test]
1580            fn args_have_no_audio_flag(job in arb_encode_job()) {
1581                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1582                    assert!(has_arg(&args, "-an"),
1583                        "missing -an: {args:?}");
1584                }
1585            }
1586
1587            /// Invariant: -c:v <codec> present and matches the job codec.
1588            #[test]
1589            fn args_have_correct_codec(job in arb_encode_job()) {
1590                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1591                    assert!(has_pair(&args, "-c:v", job.codec.as_str()),
1592                        "missing or wrong -c:v: {args:?}, expected {}", job.codec.as_str());
1593                }
1594            }
1595
1596            /// Invariant: the output path is the final argument.
1597            #[test]
1598            fn output_is_the_last_argument(job in arb_encode_job()) {
1599                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1600                    assert_eq!(args.last().unwrap(), "output.mp4",
1601                        "output not last: {args:?}");
1602                }
1603            }
1604
1605            /// Invariant: no duplicate flag keys (e.g. two -crf, two -preset).
1606            /// FFmpeg uses the last value for duplicate flags, which is a common source of bugs.
1607            #[test]
1608            fn no_duplicate_flag_keys(job in arb_encode_job()) {
1609                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1610                    let mut seen = std::collections::HashSet::new();
1611                    for arg_chunk in args.chunks(2) {
1612                        if arg_chunk[0].starts_with('-') {
1613                            assert!(seen.insert(&arg_chunk[0]),
1614                                "duplicate flag {} in {args:?}", arg_chunk[0]);
1615                        }
1616                    }
1617                }
1618            }
1619
1620            /// Invariant: for software codecs with CRF mode, -crf <value> present.
1621            #[test]
1622            fn sw_crf_has_crf_flag(
1623                crf in 0i32..63i32,
1624                preset in ".*",
1625            ) {
1626                for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
1627                    let job = EncodeJob {
1628                        codec: *codec, crf, rate_control: RateControlMode::Crf,
1629                        preset: preset.clone(), resolution: None, extra_args: vec![],
1630                        ..sample_job(RateControlMode::Crf)
1631                    };
1632                    let args = build_encode_args(&job, EncodePass::Single).unwrap();
1633                    assert!(has_pair(&args, "-crf", &crf.to_string()),
1634                        "{codec:?}: missing -crf {crf} in {args:?}");
1635                }
1636            }
1637
1638            /// Invariant: CRF and QP are mutually exclusive for software codecs.
1639            #[test]
1640            fn sw_crf_and_qp_never_both_present(
1641                crf in 0i32..63i32,
1642                mode in prop_oneof![Just(RateControlMode::Crf), Just(RateControlMode::Qp)],
1643            ) {
1644                for codec in &[Codec::X264, Codec::X265] {
1645                    let job = EncodeJob {
1646                        codec: *codec, crf, rate_control: mode, preset: String::new(),
1647                        resolution: None, extra_args: vec![],
1648                        ..sample_job(mode)
1649                    };
1650                    if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1651                        let has_crf = has_arg(&args, "-crf");
1652                        let has_qp = has_arg(&args, "-qp");
1653                        assert!(!(has_crf && has_qp),
1654                            "{codec:?} mode={mode:?}: both -crf and -qp present: {args:?}");
1655                    }
1656                }
1657            }
1658
1659            /// Invariant: for capped CRF, both -maxrate and -bufsize present with 'k' suffix.
1660            #[test]
1661            fn capped_crf_has_rate_control_args(job in arb_encode_job_sw_capped()) {
1662                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1663                    // Find -maxrate argument
1664                    let maxrate_idx = args.iter().position(|a| a == "-maxrate");
1665                    if let Some(idx) = maxrate_idx {
1666                        let val = &args[idx + 1];
1667                        assert!(val.ends_with('k'),
1668                            "-maxrate value should end with 'k': {val}");
1669                    }
1670                    let bufsize_idx = args.iter().position(|a| a == "-bufsize");
1671                    if let Some(idx) = bufsize_idx {
1672                        let val = &args[idx + 1];
1673                        assert!(val.ends_with('k'),
1674                            "-bufsize value should end with 'k': {val}");
1675                    }
1676                }
1677            }
1678
1679            /// Invariant: first-pass VBR has no progress flags, writes to null output.
1680            #[test]
1681            fn vbr_first_pass_has_null_output(
1682                job in arb_encode_job_sw_vbr(),
1683            ) {
1684                let passlog = Path::new("/tmp/plog");
1685                if let Ok(args) = build_encode_args(&job, EncodePass::First(passlog)) {
1686                    assert!(!has_arg(&args, "-progress"),
1687                        "first pass should not have -progress: {args:?}");
1688                    assert!(has_pair(&args, "-f", "null"),
1689                        "first pass must write to null: {args:?}");
1690                }
1691            }
1692
1693            /// Invariant: SVT-AV1 QP mode includes enable-adaptive-quantization=0.
1694            #[test]
1695            fn svtav1_qp_disables_aq(
1696                crf in 1i32..63i32,
1697                preset in ".*",
1698            ) {
1699                let job = EncodeJob {
1700                    codec: Codec::SvtAv1, crf, rate_control: RateControlMode::Qp,
1701                    preset, resolution: None, extra_args: vec![],
1702                    ..sample_job(RateControlMode::Qp)
1703                };
1704                let args = build_encode_args(&job, EncodePass::Single).unwrap();
1705                assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"),
1706                    "SVT-AV1 QP must disable adaptive quantization: {args:?}");
1707            }
1708
1709            /// Invariant: NVENC CRF uses -rc constqp + -cq, never -crf.
1710            #[test]
1711            fn nvenc_crf_uses_cq_not_crf(
1712                crf in 0i32..63i32,
1713                h264_h265 in prop_oneof![Just(Codec::NvencH264), Just(Codec::NvencH265)],
1714            ) {
1715                let job = EncodeJob {
1716                    codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1717                    preset: String::new(), resolution: None, extra_args: vec![],
1718                    ..sample_job(RateControlMode::Crf)
1719                };
1720                let args = build_encode_args(&job, EncodePass::Single).unwrap();
1721                assert!(has_pair(&args, "-rc", "constqp"),
1722                    "NVENC CRF missing -rc constqp: {args:?}");
1723                assert!(has_arg(&args, "-cq"),
1724                    "NVENC CRF missing -cq: {args:?}");
1725                assert!(!has_arg(&args, "-crf"),
1726                    "NVENC must not use -crf: {args:?}");
1727            }
1728
1729            /// Invariant: QSV CRF uses -global_quality, never -crf.
1730            #[test]
1731            fn qsv_crf_uses_global_quality(
1732                crf in 0i32..63i32,
1733                h264_h265 in prop_oneof![Just(Codec::QsvH264), Just(Codec::QsvH265)],
1734            ) {
1735                let job = EncodeJob {
1736                    codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1737                    preset: String::new(), resolution: None, extra_args: vec![],
1738                    ..sample_job(RateControlMode::Crf)
1739                };
1740                let args = build_encode_args(&job, EncodePass::Single).unwrap();
1741                assert!(has_arg(&args, "-global_quality"),
1742                    "QSV CRF missing -global_quality: {args:?}");
1743                assert!(!has_arg(&args, "-crf"),
1744                    "QSV must not use -crf: {args:?}");
1745            }
1746
1747            /// Invariant: AMF CRF uses -qp_i -qp_p -usage transcoding, never -crf.
1748            #[test]
1749            fn amf_crf_uses_qp_pairs(
1750                crf in 0i32..63i32,
1751                h264_h265 in prop_oneof![Just(Codec::AmfH264), Just(Codec::AmfH265)],
1752            ) {
1753                let job = EncodeJob {
1754                    codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1755                    preset: String::new(), resolution: None, extra_args: vec![],
1756                    ..sample_job(RateControlMode::Crf)
1757                };
1758                let args = build_encode_args(&job, EncodePass::Single).unwrap();
1759                assert!(has_pair(&args, "-qp_i", &crf.to_string()),
1760                    "AMF missing -qp_i: {args:?}");
1761                assert!(!has_arg(&args, "-crf"),
1762                    "AMF must not use -crf: {args:?}");
1763            }
1764
1765            /// Invariant: VideoToolbox QP is rejected (not supported).
1766            #[test]
1767            fn videotoolbox_qp_always_rejected(
1768                crf in 0i32..63i32,
1769                h264_h265 in prop_oneof![Just(Codec::VideoToolboxH264), Just(Codec::VideoToolboxH265)],
1770            ) {
1771                let job = EncodeJob {
1772                    codec: h264_h265, crf, rate_control: RateControlMode::Qp,
1773                    preset: String::new(), resolution: None, extra_args: vec![],
1774                    ..sample_job(RateControlMode::Qp)
1775                };
1776                assert!(build_encode_args(&job, EncodePass::Single).is_err(),
1777                    "VideoToolbox QP should be rejected");
1778            }
1779
1780            /// Invariant: VBR single-pass always errors for software codecs
1781            /// (hardware encoders support single-pass VBR natively).
1782            #[test]
1783            fn vbr_single_pass_errors_for_sw_codecs(
1784                target_br in 100.0f64..100000.0f64,
1785            ) {
1786                for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
1787                    let job = EncodeJob {
1788                        codec: *codec, rate_control: RateControlMode::Vbr,
1789                        target_bitrate: target_br,
1790                        ..sample_job(RateControlMode::Vbr)
1791                    };
1792                    assert!(build_encode_args(&job, EncodePass::Single).is_err(),
1793                        "{codec:?} VBR single-pass should error");
1794                }
1795            }
1796
1797            /// Invariant: hardware VBR single-pass is valid (sets bitrate args without passlog).
1798            #[test]
1799            fn hw_vbr_single_pass_is_valid(
1800                target_br in 100.0f64..100000.0f64,
1801                codec in prop_oneof![
1802                    Just(Codec::NvencH264), Just(Codec::QsvH264),
1803                    Just(Codec::VideoToolboxH264), Just(Codec::VaapiH264), Just(Codec::AmfH264),
1804                ],
1805            ) {
1806                let job = EncodeJob {
1807                    codec, rate_control: RateControlMode::Vbr,
1808                    target_bitrate: target_br,
1809                    resolution: None, preset: String::new(), extra_args: vec![],
1810                    ..sample_job(RateControlMode::Vbr)
1811                };
1812                let args = build_encode_args(&job, EncodePass::Single).unwrap();
1813                assert!(has_pair(&args, "-b:v", &format!("{target_br:.0}k")),
1814                    "HW VBR single-pass missing -b:v: {args:?}");
1815            }
1816
1817            /// Invariant: for any valid single-pass job, output is a single file path (not null).
1818            #[test]
1819            fn single_pass_output_is_file(job in arb_encode_job()) {
1820                if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1821                    let last = args.last().unwrap();
1822                    assert!(!last.starts_with('-'),
1823                        "last arg should not be a flag: {last}");
1824                    assert!(!last.is_empty(),
1825                        "last arg should not be empty");
1826                }
1827            }
1828        }
1829
1830        fn arb_encode_job_sw_capped() -> impl Strategy<Value = EncodeJob> {
1831            (any::<i32>(), any::<f64>(), any::<f64>(), any::<String>()).prop_map(
1832                |(crf, max_br, bufsize, preset)| {
1833                    let crf = crf.abs().min(63);
1834                    let max_br = max_br.abs().clamp(100.0, 100000.0);
1835                    EncodeJob {
1836                        codec: Codec::X264,
1837                        crf,
1838                        rate_control: RateControlMode::CappedCrf,
1839                        max_bitrate: max_br,
1840                        bufsize: bufsize.abs().min(200000.0),
1841                        preset,
1842                        resolution: None,
1843                        extra_args: vec![],
1844                        ..sample_job(RateControlMode::CappedCrf)
1845                    }
1846                },
1847            )
1848        }
1849
1850        fn arb_encode_job_sw_vbr() -> impl Strategy<Value = EncodeJob> {
1851            (any::<i32>(), any::<f64>(), any::<String>()).prop_map(|(crf, target_br, preset)| {
1852                let crf = crf.abs().min(63);
1853                let target_br = target_br.abs().clamp(100.0, 100000.0);
1854                EncodeJob {
1855                    codec: Codec::X264,
1856                    crf,
1857                    rate_control: RateControlMode::Vbr,
1858                    target_bitrate: target_br,
1859                    preset,
1860                    resolution: None,
1861                    extra_args: vec![],
1862                    ..sample_job(RateControlMode::Vbr)
1863                }
1864            })
1865        }
1866    }
1867}