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