Skip to main content

viser_ffmpeg/
encode.rs

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