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