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