Skip to main content

viser_ffmpeg/
encode.rs

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