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