Skip to main content

viser_ffmpeg/
encode.rs

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