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