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