1use std::path::{Path, PathBuf};
2use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
3use tokio::io::AsyncBufReadExt;
4use tokio::process::Command;
5
6use crate::{Codec, EncoderBackend, RateControlMode, Resolution, ffmpeg_path, probe};
7
8#[derive(Debug, Clone)]
10pub struct EncodeJob {
11 pub input: String,
13 pub output: String,
15 pub resolution: Option<Resolution>,
17 pub codec: Codec,
19 pub crf: i32,
21 pub rate_control: RateControlMode,
23 pub target_bitrate: f64, pub max_bitrate: f64, pub bufsize: f64, pub preset: String,
31 pub extra_args: Vec<String>,
33}
34
35#[derive(Debug, Clone)]
37pub struct EncodeResult {
38 pub job: EncodeJob,
40 pub bitrate: f64, pub file_size: u64, pub duration: Duration, }
47
48#[derive(Debug, Clone, Default)]
50pub struct Progress {
51 pub frame: i64,
53 pub fps: f64,
55 pub bitrate: f64, pub speed: f64, pub time: Duration,
61}
62
63pub async fn encode(
65 job: EncodeJob,
66 progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
67) -> anyhow::Result<EncodeResult> {
68 match job.rate_control {
69 RateControlMode::Vbr => encode_two_pass(job, progress_tx).await,
70 _ => encode_single_pass(job, progress_tx).await,
71 }
72}
73
74async fn encode_single_pass(
75 job: EncodeJob,
76 progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
77) -> anyhow::Result<EncodeResult> {
78 let args = build_encode_args(&job, EncodePass::Single)?;
79 run_encode(job, args, progress_tx).await
80}
81
82async fn encode_two_pass(
83 job: EncodeJob,
84 progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
85) -> anyhow::Result<EncodeResult> {
86 if job.target_bitrate <= 0.0 {
87 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
88 }
89
90 let passlog_prefix = make_passlog_prefix(&job.output);
91 let cleanup = PasslogCleanup::new(passlog_prefix.clone());
92
93 let first_pass_args = build_encode_args(&job, EncodePass::First(&passlog_prefix))?;
94 run_ffmpeg(first_pass_args, None).await?;
95
96 let second_pass_args = build_encode_args(&job, EncodePass::Second(&passlog_prefix))?;
97 let result = run_encode(job, second_pass_args, progress_tx).await;
98
99 cleanup.run();
100 result
101}
102
103async fn run_encode(
104 job: EncodeJob,
105 args: Vec<String>,
106 progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
107) -> anyhow::Result<EncodeResult> {
108 let start = Instant::now();
109 run_ffmpeg(args, progress_tx).await?;
110
111 let elapsed = start.elapsed();
112
113 let meta = std::fs::metadata(&job.output)
115 .map_err(|e| anyhow::anyhow!("failed to stat output: {e}"))?;
116
117 let probe_result = probe(&job.output).await?;
118 let bitrate = probe_result.format.bit_rate as f64 / 1000.0;
119
120 Ok(EncodeResult { job, bitrate, file_size: meta.len(), duration: elapsed })
121}
122
123async fn run_ffmpeg(
124 args: Vec<String>,
125 progress_tx: Option<tokio::sync::mpsc::Sender<Progress>>,
126) -> anyhow::Result<()> {
127 let mut cmd = Command::new(ffmpeg_path());
128 cmd.args(&args).stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped());
129
130 let mut child = cmd.spawn().map_err(|e| anyhow::anyhow!("failed to start ffmpeg: {e}"))?;
131
132 if let Some(stdout) = child.stdout.take() {
134 let tx = progress_tx.clone();
135 tokio::spawn(async move {
136 let reader = tokio::io::BufReader::new(stdout);
137 let mut lines = reader.lines();
138 let mut p = Progress::default();
139 while let Ok(Some(line)) = lines.next_line().await {
140 if parse_progress_line(&line, &mut p)
141 && let Some(ref tx) = tx
142 {
143 let _ = tx.try_send(p.clone());
144 }
145 }
146 });
147 }
148
149 let output = child.wait_with_output().await?;
150 if !output.status.success() {
151 let stderr = String::from_utf8_lossy(&output.stderr);
152 anyhow::bail!("ffmpeg encode failed: {stderr}");
153 }
154
155 Ok(())
156}
157
158pub async fn extract(input: &str, output: &str, start: f64, duration: f64) -> anyhow::Result<()> {
160 let args = vec![
161 "-y".to_string(),
162 "-ss".into(),
163 format!("{start:.6}"),
164 "-i".into(),
165 input.into(),
166 "-t".into(),
167 format!("{duration:.6}"),
168 "-c".into(),
169 "copy".into(),
170 "-avoid_negative_ts".into(),
171 "make_zero".into(),
172 output.into(),
173 ];
174
175 let output = Command::new(ffmpeg_path())
176 .args(&args)
177 .stderr(std::process::Stdio::piped())
178 .output()
179 .await?;
180
181 if !output.status.success() {
182 let stderr = String::from_utf8_lossy(&output.stderr);
183 anyhow::bail!("ffmpeg extract failed: {stderr}");
184 }
185 Ok(())
186}
187
188pub async fn concat(inputs: &[String], output: &str) -> anyhow::Result<()> {
190 if inputs.is_empty() {
191 anyhow::bail!("cannot concat an empty input list");
192 }
193
194 let list_path = make_concat_list_path(output);
195 let list_body = inputs
196 .iter()
197 .map(|path| format!("file '{}'", path.replace('\'', "'\\''")))
198 .collect::<Vec<_>>()
199 .join("\n");
200 std::fs::write(&list_path, format!("{list_body}\n"))?;
201
202 let args = vec![
203 "-y".to_string(),
204 "-f".into(),
205 "concat".into(),
206 "-safe".into(),
207 "0".into(),
208 "-i".into(),
209 list_path.to_string_lossy().into_owned(),
210 "-c".into(),
211 "copy".into(),
212 output.into(),
213 ];
214
215 let result = run_ffmpeg(args, None).await;
216 let _ = std::fs::remove_file(&list_path);
217 result
218}
219
220enum EncodePass<'a> {
221 Single,
222 First(&'a Path),
223 Second(&'a Path),
224}
225
226fn build_encode_args(job: &EncodeJob, pass: EncodePass<'_>) -> anyhow::Result<Vec<String>> {
227 let mut args = vec!["-y".into(), "-i".into(), job.input.clone(), "-an".into()];
228
229 if !matches!(pass, EncodePass::First(_)) {
230 args.extend(["-progress".into(), "pipe:1".into(), "-nostats".into()]);
231 }
232
233 args.extend(["-c:v".into(), job.codec.as_str().into()]);
234
235 if job.codec.is_hardware() {
236 build_hw_args(&mut args, job, &pass)?;
237 } else {
238 build_sw_args(&mut args, job, &pass)?;
239 }
240
241 if !job.preset.is_empty() {
242 if job.codec.is_hardware() {
243 add_hw_preset(&mut args, job.codec, &job.preset);
244 } else {
245 args.extend(["-preset".into(), job.preset.clone()]);
246 }
247 }
248
249 if let Some(ref res) = job.resolution
250 && res.width > 0
251 && res.height > 0
252 {
253 args.extend(["-vf".into(), format!("scale={}:{}:flags=lanczos", res.width, res.height)]);
254 }
255
256 args.extend(job.extra_args.iter().cloned());
257
258 match pass {
259 EncodePass::First(_) => {
260 args.extend(["-f".into(), "null".into()]);
261 args.push(null_output_path().into());
262 }
263 EncodePass::Single | EncodePass::Second(_) => args.push(job.output.clone()),
264 }
265
266 Ok(args)
267}
268
269fn build_sw_args(
270 args: &mut Vec<String>,
271 job: &EncodeJob,
272 pass: &EncodePass<'_>,
273) -> anyhow::Result<()> {
274 match job.rate_control {
275 RateControlMode::Qp => {
276 if job.codec == Codec::SvtAv1 {
277 args.extend(["-qp".into(), job.crf.to_string()]);
278 args.extend(["-svtav1-params".into(), "enable-adaptive-quantization=0".into()]);
279 } else {
280 args.extend(["-qp".into(), job.crf.to_string()]);
281 }
282 }
283 RateControlMode::CappedCrf => {
284 if job.max_bitrate <= 0.0 {
285 anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
286 }
287 let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
288 args.extend(["-crf".into(), job.crf.to_string()]);
289 args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
290 args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
291 }
292 RateControlMode::Vbr => {
293 if job.target_bitrate <= 0.0 {
294 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
295 }
296 args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
297 args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
298 args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
299
300 let passlog = match pass {
301 EncodePass::First(path) => {
302 args.extend(["-pass".into(), "1".into()]);
303 path
304 }
305 EncodePass::Second(path) => {
306 args.extend(["-pass".into(), "2".into()]);
307 path
308 }
309 EncodePass::Single => {
310 anyhow::bail!("VBR mode requires a two-pass encode flow");
311 }
312 };
313 args.extend(["-passlogfile".into(), passlog.to_string_lossy().into_owned()]);
314 }
315 RateControlMode::Crf => {
316 args.extend(["-crf".into(), job.crf.to_string()]);
317 }
318 }
319 Ok(())
320}
321
322fn build_hw_args(
323 args: &mut Vec<String>,
324 job: &EncodeJob,
325 _pass: &EncodePass<'_>,
326) -> anyhow::Result<()> {
327 let backend = job.codec.backend();
328 match job.rate_control {
329 RateControlMode::Crf | RateControlMode::CappedCrf => {
330 match backend {
331 EncoderBackend::Nvenc => {
332 let cq = crf_to_nvenc_cq(job.crf);
333 args.extend(["-cq".into(), cq.to_string()]);
334 args.extend(["-rc".into(), "constqp".into()]);
335 }
336 EncoderBackend::Qsv => {
337 let gq = crf_to_qsv_quality(job.crf);
338 args.extend(["-global_quality".into(), gq.to_string()]);
339 }
340 EncoderBackend::VideoToolbox => {
341 let qual = crf_to_vt_quality(job.crf);
342 args.extend(["-quality".into(), qual.to_string()]);
343 }
344 EncoderBackend::Vaapi => {
345 let gq = crf_to_qsv_quality(job.crf);
346 args.extend(["-global_quality".into(), gq.to_string()]);
347 }
348 EncoderBackend::Amf => {
349 args.extend(["-qp_i".into(), job.crf.to_string()]);
350 args.extend(["-qp_p".into(), (job.crf + 2).to_string()]);
351 args.extend(["-usage".into(), "transcoding".into()]);
352 }
353 EncoderBackend::Software => unreachable!(),
354 }
355 if let RateControlMode::CappedCrf = job.rate_control {
357 if job.max_bitrate <= 0.0 {
358 anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
359 }
360 let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
361 args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
362 args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
363 }
364 }
365 RateControlMode::Qp => match backend {
366 EncoderBackend::VideoToolbox => {
367 anyhow::bail!("VideoToolbox does not support QP rate control mode");
368 }
369 _ => {
370 args.extend(["-qp".into(), job.crf.to_string()]);
371 }
372 },
373 RateControlMode::Vbr => {
374 if job.target_bitrate <= 0.0 {
375 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
376 }
377 args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
378 args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
379 args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
380
381 if backend == EncoderBackend::Nvenc {
382 args.extend(["-rc".into(), "vbr_hq".into()]);
383 }
384 }
385 }
386 Ok(())
387}
388
389fn crf_to_nvenc_cq(crf: i32) -> i32 {
390 let cq = (crf * 51) / 63;
391 cq.clamp(1, 51)
392}
393
394fn crf_to_qsv_quality(crf: i32) -> i32 {
395 let gq = 100 - ((crf * 100) / 51);
396 gq.clamp(1, 100)
397}
398
399fn crf_to_vt_quality(crf: i32) -> f64 {
400 let q = 1.0 - (crf as f64 / 51.0);
401 q.clamp(0.0, 1.0)
402}
403
404fn add_hw_preset(args: &mut Vec<String>, codec: Codec, preset: &str) {
405 match codec.backend() {
406 EncoderBackend::Nvenc => {
407 let p = map_nvenc_preset(preset);
408 args.extend(["-preset".into(), p.into()]);
409 }
410 EncoderBackend::Qsv => {
411 args.extend(["-preset".into(), preset.to_string()]);
412 }
413 EncoderBackend::Vaapi => {
414 args.extend(["-compression_level".into(), map_vaapi_preset(preset).into()]);
415 }
416 EncoderBackend::Amf => {
417 args.extend(["-quality".into(), map_amf_quality(preset).into()]);
418 }
419 EncoderBackend::VideoToolbox => {
420 if preset == "ultrafast" || preset == "superfast" || preset == "veryfast" {
421 args.extend(["-realtime".into(), "1".into()]);
422 }
423 }
424 EncoderBackend::Software => unreachable!(),
425 }
426}
427
428fn map_nvenc_preset(preset: &str) -> &str {
429 match preset {
430 "ultrafast" | "superfast" => "p1",
431 "veryfast" => "p2",
432 "faster" => "p3",
433 "fast" => "p4",
434 "medium" => "p5",
435 "slow" => "p6",
436 "slower" | "veryslow" => "p7",
437 other => other,
438 }
439}
440
441fn map_vaapi_preset(preset: &str) -> &str {
442 match preset {
443 "ultrafast" | "superfast" => "1",
444 "veryfast" | "faster" => "2",
445 "fast" | "medium" => "3",
446 "slow" => "4",
447 "slower" | "veryslow" => "5",
448 other => other,
449 }
450}
451
452fn map_amf_quality(preset: &str) -> &str {
453 match preset {
454 "ultrafast" | "superfast" => "speed",
455 "veryfast" | "faster" | "fast" => "balanced",
456 "medium" | "slow" | "slower" | "veryslow" => "quality",
457 other => other,
458 }
459}
460
461fn make_passlog_prefix(output: &str) -> PathBuf {
462 let output_path = Path::new(output);
463 let parent =
464 output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
465 let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
466 let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
467 parent.join(format!(".{stem}.viser-passlog-{unique}-{}", std::process::id()))
468}
469
470fn make_concat_list_path(output: &str) -> PathBuf {
471 let output_path = Path::new(output);
472 let parent =
473 output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
474 let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
475 let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
476 parent.join(format!(".{stem}.viser-concat-{unique}-{}.txt", std::process::id()))
477}
478
479fn null_output_path() -> &'static str {
480 if cfg!(windows) { "NUL" } else { "/dev/null" }
481}
482
483struct PasslogCleanup {
484 parent: PathBuf,
485 prefix: String,
486}
487
488impl PasslogCleanup {
489 fn new(path: PathBuf) -> Self {
490 let parent = path.parent().unwrap_or(Path::new(".")).to_path_buf();
491 let prefix = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_owned();
492 Self { parent, prefix }
493 }
494
495 fn run(&self) {
496 let Ok(entries) = std::fs::read_dir(&self.parent) else {
497 return;
498 };
499
500 for entry in entries.flatten() {
501 let path = entry.path();
502 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
503 continue;
504 };
505 if !name.starts_with(&self.prefix) {
506 continue;
507 }
508 if let Err(err) = std::fs::remove_file(&path) {
509 tracing::debug!(?path, ?err, "failed to remove ffmpeg two-pass log file");
510 }
511 }
512 }
513}
514
515fn parse_progress_line(line: &str, p: &mut Progress) -> bool {
517 let Some((key, value)) = line.split_once('=') else {
518 return false;
519 };
520
521 match key {
522 "frame" => {
523 p.frame = value.parse().unwrap_or(0);
524 }
525 "fps" => {
526 p.fps = value.parse().unwrap_or(0.0);
527 }
528 "bitrate" => {
529 let v = value.trim_end_matches("kbits/s");
530 p.bitrate = v.parse().unwrap_or(0.0);
531 }
532 "speed" => {
533 let v = value.trim_end_matches('x');
534 p.speed = v.parse().unwrap_or(0.0);
535 }
536 "out_time_us" => {
537 let us: u64 = value.parse().unwrap_or(0);
538 p.time = Duration::from_micros(us);
539 }
540 "progress" => return true,
541 _ => {}
542 }
543 false
544}
545
546#[cfg(test)]
547mod tests {
548 use super::*;
549 use crate::Codec;
550
551 fn sample_job(mode: RateControlMode) -> EncodeJob {
552 EncodeJob {
553 input: "input.mp4".into(),
554 output: "output.mp4".into(),
555 resolution: Some(crate::Resolution::new(1280, 720)),
556 codec: Codec::X264,
557 crf: 23,
558 rate_control: mode,
559 target_bitrate: 2500.0,
560 max_bitrate: 3000.0,
561 bufsize: 6000.0,
562 preset: "medium".into(),
563 extra_args: vec![],
564 }
565 }
566
567 fn job_with_codec(codec: Codec, mode: RateControlMode) -> EncodeJob {
568 EncodeJob { codec, rate_control: mode, ..sample_job(mode) }
569 }
570
571 fn has_pair(args: &[String], a: &str, b: &str) -> bool {
573 args.windows(2).any(|w| w[0] == a && w[1] == b)
574 }
575
576 fn has_arg(args: &[String], a: &str) -> bool {
577 args.iter().any(|s| s == a)
578 }
579
580 #[test]
582 fn test_build_encode_args_crf_single_pass() {
583 let args =
584 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
585 assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
586 assert_eq!(args.last().unwrap(), "output.mp4");
587 }
588
589 #[test]
590 fn test_x264_crf_args() {
591 let args = build_encode_args(
592 &job_with_codec(Codec::X264, RateControlMode::Crf),
593 EncodePass::Single,
594 )
595 .unwrap();
596 assert!(has_pair(&args, "-c:v", "libx264"));
597 assert!(has_pair(&args, "-crf", "23"));
598 assert!(has_pair(&args, "-preset", "medium"));
599 }
600
601 #[test]
602 fn test_x265_crf_args() {
603 let args = build_encode_args(
604 &job_with_codec(Codec::X265, RateControlMode::Crf),
605 EncodePass::Single,
606 )
607 .unwrap();
608 assert!(has_pair(&args, "-c:v", "libx265"));
609 assert!(has_pair(&args, "-crf", "23"));
610 }
611
612 #[test]
613 fn test_svtav1_crf_args() {
614 let args = build_encode_args(
615 &job_with_codec(Codec::SvtAv1, RateControlMode::Crf),
616 EncodePass::Single,
617 )
618 .unwrap();
619 assert!(has_pair(&args, "-c:v", "libsvtav1"));
620 assert!(has_pair(&args, "-crf", "23"));
621 }
622
623 #[test]
625 fn test_x264_qp_args() {
626 let args = build_encode_args(
627 &job_with_codec(Codec::X264, RateControlMode::Qp),
628 EncodePass::Single,
629 )
630 .unwrap();
631 assert!(has_pair(&args, "-qp", "23"));
632 assert!(!has_arg(&args, "-crf"));
633 }
634
635 #[test]
636 fn test_x265_qp_args() {
637 let args = build_encode_args(
638 &job_with_codec(Codec::X265, RateControlMode::Qp),
639 EncodePass::Single,
640 )
641 .unwrap();
642 assert!(has_pair(&args, "-qp", "23"));
643 }
644
645 #[test]
646 fn test_svtav1_qp_adds_adaptive_quantization_off() {
647 let args = build_encode_args(
648 &job_with_codec(Codec::SvtAv1, RateControlMode::Qp),
649 EncodePass::Single,
650 )
651 .unwrap();
652 assert!(has_pair(&args, "-qp", "23"));
653 assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"));
654 }
655
656 #[test]
658 fn test_build_encode_args_capped_crf_sets_vbv() {
659 let args =
660 build_encode_args(&sample_job(RateControlMode::CappedCrf), EncodePass::Single).unwrap();
661 assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
662 assert!(args.windows(2).any(|w| w == ["-maxrate", "3000k"]));
663 assert!(args.windows(2).any(|w| w == ["-bufsize", "6000k"]));
664 }
665
666 #[test]
667 fn test_capped_crf_max_bitrate_zero_errors() {
668 let job = EncodeJob {
669 max_bitrate: 0.0,
670 rate_control: RateControlMode::CappedCrf,
671 ..sample_job(RateControlMode::CappedCrf)
672 };
673 assert!(build_encode_args(&job, EncodePass::Single).is_err());
674 }
675
676 #[test]
677 fn test_capped_crf_max_bitrate_negative_errors() {
678 let job = EncodeJob {
679 max_bitrate: -1.0,
680 rate_control: RateControlMode::CappedCrf,
681 ..sample_job(RateControlMode::CappedCrf)
682 };
683 assert!(build_encode_args(&job, EncodePass::Single).is_err());
684 }
685
686 #[test]
687 fn test_capped_crf_auto_bufsize_when_zero() {
688 let job = EncodeJob {
689 max_bitrate: 4000.0,
690 bufsize: 0.0,
691 rate_control: RateControlMode::CappedCrf,
692 ..sample_job(RateControlMode::CappedCrf)
693 };
694 let args = build_encode_args(&job, EncodePass::Single).unwrap();
695 assert!(has_pair(&args, "-bufsize", "8000k"));
696 }
697
698 #[test]
700 fn test_vbr_target_bitrate_zero_errors() {
701 let job = EncodeJob {
702 target_bitrate: 0.0,
703 rate_control: RateControlMode::Vbr,
704 ..sample_job(RateControlMode::Vbr)
705 };
706 assert!(build_encode_args(&job, EncodePass::First(Path::new("passlog"))).is_err());
707 }
708
709 #[test]
710 fn test_vbr_single_pass_errors() {
711 assert!(build_encode_args(&sample_job(RateControlMode::Vbr), EncodePass::Single).is_err());
712 }
713
714 #[test]
715 fn test_build_encode_args_vbr_first_pass_uses_null_output() {
716 let job = sample_job(RateControlMode::Vbr);
717 let passlog = Path::new("/tmp/viser-passlog");
718 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
719 assert!(args.windows(2).any(|w| w == ["-pass", "1"]));
720 assert!(args.windows(2).any(|w| w == ["-f", "null"]));
721 assert_eq!(args.last().unwrap(), null_output_path());
722 }
723
724 #[test]
725 fn test_build_encode_args_vbr_second_pass_writes_output() {
726 let job = sample_job(RateControlMode::Vbr);
727 let passlog = Path::new("/tmp/viser-passlog");
728 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
729 assert!(args.windows(2).any(|w| w == ["-pass", "2"]));
730 assert_eq!(args.last().unwrap(), "output.mp4");
731 }
732
733 #[test]
734 fn test_vbr_first_pass_no_progress_args() {
735 let job = sample_job(RateControlMode::Vbr);
736 let passlog = Path::new("/tmp/viser-passlog");
737 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
738 assert!(!has_arg(&args, "-progress"));
739 assert!(!has_arg(&args, "-nostats"));
740 }
741
742 #[test]
743 fn test_vbr_second_pass_sets_bitrate_and_vbv() {
744 let job = sample_job(RateControlMode::Vbr);
745 let passlog = Path::new("/tmp/viser-passlog");
746 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
747 assert!(has_pair(&args, "-b:v", "2500k"));
748 assert!(has_pair(&args, "-maxrate", "5000k"));
749 assert!(has_pair(&args, "-bufsize", "10000k"));
750 }
751
752 #[test]
753 fn test_vbr_sets_passlog() {
754 let job = sample_job(RateControlMode::Vbr);
755 let passlog = Path::new("/tmp/viser-passlog");
756 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
757 assert!(has_arg(&args, "/tmp/viser-passlog"));
758 }
759
760 #[test]
762 fn test_resolution_scaling_adds_vf() {
763 let args =
764 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
765 assert!(has_arg(&args, "-vf"));
766 assert!(has_arg(&args, "scale=1280:720:flags=lanczos"));
767 }
768
769 #[test]
770 fn test_zero_width_skips_scale() {
771 let job = EncodeJob {
772 resolution: Some(crate::Resolution::new(0, 720)),
773 ..sample_job(RateControlMode::Crf)
774 };
775 let args = build_encode_args(&job, EncodePass::Single).unwrap();
776 assert!(!has_arg(&args, "-vf"));
777 }
778
779 #[test]
780 fn test_zero_height_skips_scale() {
781 let job = EncodeJob {
782 resolution: Some(crate::Resolution::new(1280, 0)),
783 ..sample_job(RateControlMode::Crf)
784 };
785 let args = build_encode_args(&job, EncodePass::Single).unwrap();
786 assert!(!has_arg(&args, "-vf"));
787 }
788
789 #[test]
790 fn test_no_resolution_skips_scale() {
791 let job = EncodeJob { resolution: None, ..sample_job(RateControlMode::Crf) };
792 let args = build_encode_args(&job, EncodePass::Single).unwrap();
793 assert!(!has_arg(&args, "-vf"));
794 }
795
796 #[test]
797 fn test_resolution_negative_skip_scale() {
798 let job = EncodeJob {
799 resolution: Some(crate::Resolution::new(-1, -1)),
800 ..sample_job(RateControlMode::Crf)
801 };
802 let args = build_encode_args(&job, EncodePass::Single).unwrap();
803 assert!(!has_arg(&args, "-vf"));
804 }
805
806 #[test]
808 fn test_empty_preset_no_preset_arg() {
809 let job = EncodeJob { preset: String::new(), ..sample_job(RateControlMode::Crf) };
810 let args = build_encode_args(&job, EncodePass::Single).unwrap();
811 assert!(!has_arg(&args, "-preset"));
812 }
813
814 #[test]
815 fn test_preset_with_x264() {
816 let job = EncodeJob {
817 codec: Codec::X264,
818 preset: "fast".into(),
819 ..sample_job(RateControlMode::Crf)
820 };
821 let args = build_encode_args(&job, EncodePass::Single).unwrap();
822 assert!(has_pair(&args, "-preset", "fast"));
823 }
824
825 #[test]
827 fn test_extra_args_appended_before_output() {
828 let job = EncodeJob {
829 extra_args: vec!["-g".into(), "30".into(), "-bf".into(), "2".into()],
830 ..sample_job(RateControlMode::Crf)
831 };
832 let args = build_encode_args(&job, EncodePass::Single).unwrap();
833 assert!(has_pair(&args, "-g", "30"));
834 assert!(has_pair(&args, "-bf", "2"));
835 assert_eq!(args.last().unwrap(), "output.mp4");
836 }
837
838 #[test]
840 fn test_null_output_path_is_platform_appropriate() {
841 let null = null_output_path();
842 assert!(!null.is_empty());
843 assert!(null == "/dev/null" || null == "NUL");
844 }
845
846 #[test]
848 fn test_parse_progress_line_frame() {
849 let mut p = Progress::default();
850 assert!(!parse_progress_line("frame=100", &mut p));
851 assert_eq!(p.frame, 100);
852 }
853
854 #[test]
855 fn test_parse_progress_line_fps() {
856 let mut p = Progress::default();
857 parse_progress_line("fps=23.976", &mut p);
858 assert!((p.fps - 23.976).abs() < 0.001);
859 }
860
861 #[test]
862 fn test_parse_progress_line_bitrate() {
863 let mut p = Progress::default();
864 parse_progress_line("bitrate=1500.5kbits/s", &mut p);
865 assert!((p.bitrate - 1500.5).abs() < 0.001);
866 }
867
868 #[test]
869 fn test_parse_progress_line_speed() {
870 let mut p = Progress::default();
871 parse_progress_line("speed=1.5x", &mut p);
872 assert!((p.speed - 1.5).abs() < 0.001);
873 }
874
875 #[test]
876 fn test_parse_progress_line_out_time_us() {
877 let mut p = Progress::default();
878 parse_progress_line("out_time_us=1234567", &mut p);
879 assert_eq!(p.time, Duration::from_micros(1234567));
880 }
881
882 #[test]
883 fn test_parse_progress_returns_true_on_progress() {
884 let mut p = Progress::default();
885 assert!(parse_progress_line("progress=continue", &mut p));
886 }
887
888 #[test]
889 fn test_parse_progress_full_block() {
890 let mut p = Progress::default();
891 parse_progress_line("frame=1500", &mut p);
892 parse_progress_line("fps=25.0", &mut p);
893 parse_progress_line("bitrate=2000.0kbits/s", &mut p);
894 parse_progress_line("speed=2.0x", &mut p);
895 parse_progress_line("out_time_us=60000000", &mut p);
896 assert!(parse_progress_line("progress=continue", &mut p));
897 assert_eq!(p.frame, 1500);
898 assert_eq!(p.time, Duration::from_secs(60));
899 }
900
901 #[test]
902 fn test_parse_progress_line_missing_equals() {
903 let mut p = Progress::default();
904 assert!(!parse_progress_line("noequals", &mut p));
905 }
906
907 #[test]
908 fn test_parse_progress_line_unknown_key() {
909 let mut p = Progress::default();
910 assert!(!parse_progress_line("unknown=42", &mut p));
911 }
912
913 #[test]
914 fn test_parse_progress_line_bogus_numbers() {
915 let mut p = Progress::default();
916 parse_progress_line("frame=abc", &mut p);
917 assert_eq!(p.frame, 0);
918 }
919
920 #[test]
922 fn test_make_passlog_prefix_uses_output_dir() {
923 let prefix = make_passlog_prefix("/path/to/video.mp4");
924 assert!(prefix.starts_with(Path::new("/path/to")));
925 assert!(prefix.to_string_lossy().contains("video"));
926 }
927
928 #[test]
929 fn test_make_passlog_prefix_no_parent_falls_back_to_cwd() {
930 let prefix = make_passlog_prefix("video.mp4");
931 assert!(prefix.starts_with(Path::new(".")));
932 }
933
934 #[test]
936 fn test_make_concat_list_path_is_txt() {
937 let path = make_concat_list_path("output.mp4");
938 assert!(path.to_string_lossy().ends_with(".txt"));
939 }
940
941 fn hw_crf(codec: Codec) -> EncodeJob {
943 EncodeJob {
944 codec,
945 preset: String::new(),
946 resolution: None,
947 extra_args: vec![],
948 ..sample_job(RateControlMode::Crf)
949 }
950 }
951
952 fn hw_qp(codec: Codec) -> EncodeJob {
953 EncodeJob {
954 codec,
955 preset: String::new(),
956 resolution: None,
957 extra_args: vec![],
958 ..sample_job(RateControlMode::Qp)
959 }
960 }
961
962 #[test]
964 fn test_nvenc_h264_crf_uses_constqp() {
965 let args = build_encode_args(&hw_crf(Codec::NvencH264), EncodePass::Single).unwrap();
966 assert!(has_pair(&args, "-rc", "constqp"));
967 assert!(has_arg(&args, "-cq"));
968 }
969
970 #[test]
971 fn test_nvenc_h265_crf_uses_constqp() {
972 let args = build_encode_args(&hw_crf(Codec::NvencH265), EncodePass::Single).unwrap();
973 assert!(has_pair(&args, "-rc", "constqp"));
974 assert!(has_arg(&args, "-cq"));
975 }
976
977 #[test]
978 fn test_qsv_h264_crf_uses_global_quality() {
979 let args = build_encode_args(&hw_crf(Codec::QsvH264), EncodePass::Single).unwrap();
980 assert!(has_arg(&args, "-global_quality"));
981 }
982
983 #[test]
984 fn test_qsv_h265_crf_uses_global_quality() {
985 let args = build_encode_args(&hw_crf(Codec::QsvH265), EncodePass::Single).unwrap();
986 assert!(has_arg(&args, "-global_quality"));
987 }
988
989 #[test]
990 fn test_vt_h264_crf_uses_quality() {
991 let args = build_encode_args(&hw_crf(Codec::VideoToolboxH264), EncodePass::Single).unwrap();
992 assert!(has_arg(&args, "-quality"));
993 }
994
995 #[test]
996 fn test_vt_h265_crf_uses_quality() {
997 let args = build_encode_args(&hw_crf(Codec::VideoToolboxH265), EncodePass::Single).unwrap();
998 assert!(has_arg(&args, "-quality"));
999 }
1000
1001 #[test]
1002 fn test_vaapi_h264_crf_uses_global_quality() {
1003 let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1004 assert!(has_arg(&args, "-global_quality"));
1005 }
1006
1007 #[test]
1008 fn test_vaapi_h265_crf_uses_global_quality() {
1009 let args = build_encode_args(&hw_crf(Codec::VaapiH265), EncodePass::Single).unwrap();
1010 assert!(has_arg(&args, "-global_quality"));
1011 }
1012
1013 #[test]
1014 fn test_amf_h264_crf_uses_qp_and_usage() {
1015 let args = build_encode_args(&hw_crf(Codec::AmfH264), EncodePass::Single).unwrap();
1016 assert!(has_pair(&args, "-qp_i", "23"));
1017 assert!(has_pair(&args, "-qp_p", "25"));
1018 assert!(has_pair(&args, "-usage", "transcoding"));
1019 }
1020
1021 #[test]
1022 fn test_amf_h265_crf_uses_qp_and_usage() {
1023 let args = build_encode_args(&hw_crf(Codec::AmfH265), EncodePass::Single).unwrap();
1024 assert!(has_pair(&args, "-qp_i", "23"));
1025 assert!(has_pair(&args, "-qp_p", "25"));
1026 assert!(has_pair(&args, "-usage", "transcoding"));
1027 }
1028
1029 #[test]
1031 fn test_nvenc_h264_qp() {
1032 let args = build_encode_args(&hw_qp(Codec::NvencH264), EncodePass::Single).unwrap();
1033 assert!(has_pair(&args, "-qp", "23"));
1034 }
1035
1036 #[test]
1037 fn test_qsv_h264_qp() {
1038 let args = build_encode_args(&hw_qp(Codec::QsvH264), EncodePass::Single).unwrap();
1039 assert!(has_pair(&args, "-qp", "23"));
1040 }
1041
1042 #[test]
1043 fn test_vaapi_h264_qp() {
1044 let args = build_encode_args(&hw_qp(Codec::VaapiH264), EncodePass::Single).unwrap();
1045 assert!(has_pair(&args, "-qp", "23"));
1046 }
1047
1048 #[test]
1049 fn test_amf_h264_qp() {
1050 let args = build_encode_args(&hw_qp(Codec::AmfH264), EncodePass::Single).unwrap();
1051 assert!(has_pair(&args, "-qp", "23"));
1052 }
1053
1054 #[test]
1055 fn test_vt_qp_rejected() {
1056 let result = build_encode_args(&hw_qp(Codec::VideoToolboxH264), EncodePass::Single);
1057 assert!(result.is_err());
1058 }
1059
1060 #[test]
1061 fn test_vt_h265_qp_rejected() {
1062 let result = build_encode_args(&hw_qp(Codec::VideoToolboxH265), EncodePass::Single);
1063 assert!(result.is_err());
1064 }
1065
1066 #[test]
1068 fn test_nvenc_capped_crf_sets_vbv() {
1069 let job = EncodeJob {
1070 codec: Codec::NvencH264,
1071 max_bitrate: 5000.0,
1072 bufsize: 10000.0,
1073 rate_control: RateControlMode::CappedCrf,
1074 ..sample_job(RateControlMode::Crf)
1075 };
1076 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1077 assert!(has_pair(&args, "-rc", "constqp"));
1078 assert!(has_pair(&args, "-maxrate", "5000k"));
1079 assert!(has_pair(&args, "-bufsize", "10000k"));
1080 }
1081
1082 #[test]
1083 fn test_hw_capped_crf_max_bitrate_zero_errors() {
1084 let job = EncodeJob {
1085 codec: Codec::NvencH264,
1086 max_bitrate: 0.0,
1087 rate_control: RateControlMode::CappedCrf,
1088 ..sample_job(RateControlMode::Crf)
1089 };
1090 assert!(build_encode_args(&job, EncodePass::Single).is_err());
1091 }
1092
1093 #[test]
1095 fn test_nvenc_vbr_uses_vbr_hq() {
1096 let job = EncodeJob {
1097 codec: Codec::NvencH264,
1098 target_bitrate: 5000.0,
1099 rate_control: RateControlMode::Vbr,
1100 ..sample_job(RateControlMode::Vbr)
1101 };
1102 let passlog = Path::new("/tmp/plog");
1103 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1104 assert!(has_pair(&args, "-rc", "vbr_hq"));
1105 }
1106
1107 #[test]
1108 fn test_qsv_vbr_no_special_rc() {
1109 let job = EncodeJob {
1110 codec: Codec::QsvH264,
1111 target_bitrate: 5000.0,
1112 rate_control: RateControlMode::Vbr,
1113 ..sample_job(RateControlMode::Vbr)
1114 };
1115 let passlog = Path::new("/tmp/plog");
1116 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1117 assert!(!has_arg(&args, "-rc"));
1118 }
1119
1120 #[test]
1121 fn test_hw_vbr_target_bitrate_zero_errors() {
1122 let job = EncodeJob {
1123 codec: Codec::NvencH264,
1124 target_bitrate: 0.0,
1125 rate_control: RateControlMode::Vbr,
1126 ..sample_job(RateControlMode::Vbr)
1127 };
1128 let passlog = Path::new("/tmp/plog");
1129 assert!(build_encode_args(&job, EncodePass::Second(passlog)).is_err());
1130 }
1131
1132 #[test]
1134 fn test_nvenc_preset_maps_to_p_numbers() {
1135 let job = EncodeJob {
1136 codec: Codec::NvencH264,
1137 preset: "veryfast".into(),
1138 ..sample_job(RateControlMode::Crf)
1139 };
1140 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1141 assert!(has_pair(&args, "-preset", "p2"));
1142 }
1143
1144 #[test]
1145 fn test_vaapi_preset_uses_compression_level() {
1146 let job = EncodeJob {
1147 codec: Codec::VaapiH264,
1148 preset: "medium".into(),
1149 ..sample_job(RateControlMode::Crf)
1150 };
1151 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1152 assert!(has_pair(&args, "-compression_level", "3"));
1153 }
1154
1155 #[test]
1156 fn test_amf_preset_uses_quality() {
1157 let job = EncodeJob {
1158 codec: Codec::AmfH264,
1159 preset: "slow".into(),
1160 ..sample_job(RateControlMode::Crf)
1161 };
1162 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1163 assert!(has_pair(&args, "-quality", "quality"));
1164 }
1165
1166 #[test]
1167 fn test_amf_preset_speed() {
1168 let job = EncodeJob {
1169 codec: Codec::AmfH264,
1170 preset: "ultrafast".into(),
1171 ..sample_job(RateControlMode::Crf)
1172 };
1173 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1174 assert!(has_pair(&args, "-quality", "speed"));
1175 }
1176
1177 #[test]
1178 fn test_amf_preset_balanced() {
1179 let job = EncodeJob {
1180 codec: Codec::AmfH264,
1181 preset: "fast".into(),
1182 ..sample_job(RateControlMode::Crf)
1183 };
1184 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1185 assert!(has_pair(&args, "-quality", "balanced"));
1186 }
1187
1188 #[test]
1189 fn test_vt_preset_realtime_for_ultrafast() {
1190 let job = EncodeJob {
1191 codec: Codec::VideoToolboxH264,
1192 preset: "ultrafast".into(),
1193 ..sample_job(RateControlMode::Crf)
1194 };
1195 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1196 assert!(has_pair(&args, "-realtime", "1"));
1197 }
1198
1199 #[test]
1200 fn test_vt_preset_realtime_for_veryfast() {
1201 let job = EncodeJob {
1202 codec: Codec::VideoToolboxH264,
1203 preset: "veryfast".into(),
1204 ..sample_job(RateControlMode::Crf)
1205 };
1206 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1207 assert!(has_pair(&args, "-realtime", "1"));
1208 }
1209
1210 #[test]
1211 fn test_vt_preset_no_realtime_for_slow() {
1212 let job = EncodeJob {
1213 codec: Codec::VideoToolboxH264,
1214 preset: "slow".into(),
1215 ..sample_job(RateControlMode::Crf)
1216 };
1217 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1218 assert!(!has_arg(&args, "-realtime"));
1219 }
1220
1221 #[test]
1222 fn test_qsv_preset_passthrough() {
1223 let job = EncodeJob {
1224 codec: Codec::QsvH264,
1225 preset: "medium".into(),
1226 ..sample_job(RateControlMode::Crf)
1227 };
1228 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1229 assert!(has_pair(&args, "-preset", "medium"));
1230 }
1231
1232 #[test]
1234 fn test_crf_to_nvenc_cq_bounds() {
1235 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); }
1240
1241 #[test]
1242 fn test_crf_to_nvenc_cq_typical_values() {
1243 assert_eq!(crf_to_nvenc_cq(23), 18); assert_eq!(crf_to_nvenc_cq(30), 24); }
1246
1247 #[test]
1248 fn test_crf_to_qsv_quality_bounds() {
1249 let q0 = crf_to_qsv_quality(0);
1250 assert!((95..=100).contains(&q0)); let q51 = crf_to_qsv_quality(51);
1252 assert_eq!(q51, 1); let q100 = crf_to_qsv_quality(100);
1254 assert_eq!(q100, 1); }
1256
1257 #[test]
1258 fn test_crf_to_qsv_quality_mid() {
1259 let q = crf_to_qsv_quality(25);
1260 assert!((50..=52).contains(&q));
1262 }
1263
1264 #[test]
1265 fn test_crf_to_vt_quality_bounds() {
1266 assert!((crf_to_vt_quality(0) - 1.0).abs() < 1e-9);
1267 assert!((crf_to_vt_quality(51) - 0.0).abs() < 1e-9);
1268 assert!((crf_to_vt_quality(100) - 0.0).abs() < 1e-9);
1269 }
1270
1271 #[test]
1272 fn test_crf_to_vt_quality_mid() {
1273 let q = crf_to_vt_quality(25);
1274 assert!(q > 0.4 && q < 0.6);
1275 }
1276
1277 #[test]
1279 fn test_crf_zero() {
1280 let job = EncodeJob { crf: 0, ..sample_job(RateControlMode::Crf) };
1281 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1282 assert!(has_pair(&args, "-crf", "0"));
1283 }
1284
1285 #[test]
1286 fn test_crf_high_value() {
1287 let job = EncodeJob { crf: 51, ..sample_job(RateControlMode::Crf) };
1288 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1289 assert!(has_pair(&args, "-crf", "51"));
1290 }
1291
1292 #[test]
1293 fn test_crf_negative_allowed() {
1294 let job = EncodeJob { crf: -1, ..sample_job(RateControlMode::Crf) };
1295 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1296 assert!(has_pair(&args, "-crf", "-1"));
1297 }
1298
1299 #[test]
1300 fn test_input_arg_is_present() {
1301 let args =
1302 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1303 assert!(has_pair(&args, "-i", "input.mp4"));
1304 }
1305
1306 #[test]
1307 fn test_no_audio_flag_is_present() {
1308 let args =
1309 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1310 assert!(has_arg(&args, "-an"));
1311 }
1312
1313 #[test]
1315 fn test_all_sw_codecs_have_correct_codec_string() {
1316 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
1317 let job = EncodeJob {
1318 codec: *codec,
1319 preset: String::new(),
1320 resolution: None,
1321 extra_args: vec![],
1322 ..sample_job(RateControlMode::Crf)
1323 };
1324 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1325 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1326 }
1327 }
1328
1329 #[test]
1330 fn test_all_hw_codecs_have_correct_codec_string() {
1331 for codec in &[
1332 Codec::NvencH264,
1333 Codec::NvencH265,
1334 Codec::QsvH264,
1335 Codec::QsvH265,
1336 Codec::VideoToolboxH264,
1337 Codec::VideoToolboxH265,
1338 Codec::VaapiH264,
1339 Codec::VaapiH265,
1340 Codec::AmfH264,
1341 Codec::AmfH265,
1342 ] {
1343 let job = EncodeJob {
1344 codec: *codec,
1345 preset: String::new(),
1346 resolution: None,
1347 extra_args: vec![],
1348 ..sample_job(RateControlMode::Crf)
1349 };
1350 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1351 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1352 }
1353 }
1354
1355 #[cfg(test)]
1357 mod proptests {
1358 use super::*;
1359 use proptest::prelude::*;
1360
1361 fn arb_codec() -> impl Strategy<Value = Codec> {
1362 prop_oneof![
1363 Just(Codec::X264),
1364 Just(Codec::X265),
1365 Just(Codec::SvtAv1),
1366 Just(Codec::NvencH264),
1367 Just(Codec::NvencH265),
1368 Just(Codec::QsvH264),
1369 Just(Codec::QsvH265),
1370 Just(Codec::VideoToolboxH264),
1371 Just(Codec::VideoToolboxH265),
1372 Just(Codec::VaapiH264),
1373 Just(Codec::VaapiH265),
1374 Just(Codec::AmfH264),
1375 Just(Codec::AmfH265),
1376 ]
1377 }
1378
1379 fn arb_rate_control() -> impl Strategy<Value = RateControlMode> {
1380 prop_oneof![
1381 Just(RateControlMode::Crf),
1382 Just(RateControlMode::Qp),
1383 Just(RateControlMode::CappedCrf),
1384 ]
1385 }
1386
1387 fn arb_encode_job() -> impl Strategy<Value = EncodeJob> {
1388 (
1389 arb_codec(),
1390 arb_rate_control(),
1391 any::<i32>(),
1392 any::<f64>(),
1393 any::<f64>(),
1394 any::<f64>(),
1395 any::<String>(),
1396 )
1397 .prop_map(|(codec, rc, crf, target_br, max_br, bufsize, preset)| {
1398 let crf = crf.abs().min(63);
1399 EncodeJob {
1400 input: "input.mp4".into(),
1401 output: "output.mp4".into(),
1402 resolution: Some(Resolution::new(1920, 1080)),
1403 codec,
1404 crf,
1405 rate_control: rc,
1406 target_bitrate: target_br.abs().min(100000.0),
1407 max_bitrate: max_br.abs().min(100000.0),
1408 bufsize: bufsize.abs().min(200000.0),
1409 preset,
1410 extra_args: vec![],
1411 }
1412 })
1413 }
1414
1415 proptest! {
1416 #[test]
1418 fn args_start_with_overwrite_and_input(job in arb_encode_job()) {
1419 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1420 assert!(args.len() >= 3, "too few args: {args:?}");
1421 assert_eq!(args[0], "-y", "first arg must be -y");
1422 assert_eq!(args[1], "-i", "second arg must be -i");
1423 assert_eq!(args[2], "input.mp4", "third arg must be input path");
1424 }
1425 }
1426
1427 #[test]
1429 fn args_have_no_audio_flag(job in arb_encode_job()) {
1430 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1431 assert!(has_arg(&args, "-an"),
1432 "missing -an: {args:?}");
1433 }
1434 }
1435
1436 #[test]
1438 fn args_have_correct_codec(job in arb_encode_job()) {
1439 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1440 assert!(has_pair(&args, "-c:v", job.codec.as_str()),
1441 "missing or wrong -c:v: {args:?}, expected {}", job.codec.as_str());
1442 }
1443 }
1444
1445 #[test]
1447 fn output_is_the_last_argument(job in arb_encode_job()) {
1448 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1449 assert_eq!(args.last().unwrap(), "output.mp4",
1450 "output not last: {args:?}");
1451 }
1452 }
1453
1454 #[test]
1457 fn no_duplicate_flag_keys(job in arb_encode_job()) {
1458 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1459 let mut seen = std::collections::HashSet::new();
1460 for arg_chunk in args.chunks(2) {
1461 if arg_chunk[0].starts_with('-') {
1462 assert!(seen.insert(&arg_chunk[0]),
1463 "duplicate flag {} in {args:?}", arg_chunk[0]);
1464 }
1465 }
1466 }
1467 }
1468
1469 #[test]
1471 fn sw_crf_has_crf_flag(
1472 crf in 0i32..63i32,
1473 preset in ".*",
1474 ) {
1475 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
1476 let job = EncodeJob {
1477 codec: *codec, crf, rate_control: RateControlMode::Crf,
1478 preset: preset.clone(), resolution: None, extra_args: vec![],
1479 ..sample_job(RateControlMode::Crf)
1480 };
1481 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1482 assert!(has_pair(&args, "-crf", &crf.to_string()),
1483 "{codec:?}: missing -crf {crf} in {args:?}");
1484 }
1485 }
1486
1487 #[test]
1489 fn sw_crf_and_qp_never_both_present(
1490 crf in 0i32..63i32,
1491 mode in prop_oneof![Just(RateControlMode::Crf), Just(RateControlMode::Qp)],
1492 ) {
1493 for codec in &[Codec::X264, Codec::X265] {
1494 let job = EncodeJob {
1495 codec: *codec, crf, rate_control: mode, preset: String::new(),
1496 resolution: None, extra_args: vec![],
1497 ..sample_job(mode)
1498 };
1499 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1500 let has_crf = has_arg(&args, "-crf");
1501 let has_qp = has_arg(&args, "-qp");
1502 assert!(!(has_crf && has_qp),
1503 "{codec:?} mode={mode:?}: both -crf and -qp present: {args:?}");
1504 }
1505 }
1506 }
1507
1508 #[test]
1510 fn capped_crf_has_rate_control_args(job in arb_encode_job_sw_capped()) {
1511 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1512 let maxrate_idx = args.iter().position(|a| a == "-maxrate");
1514 if let Some(idx) = maxrate_idx {
1515 let val = &args[idx + 1];
1516 assert!(val.ends_with('k'),
1517 "-maxrate value should end with 'k': {val}");
1518 }
1519 let bufsize_idx = args.iter().position(|a| a == "-bufsize");
1520 if let Some(idx) = bufsize_idx {
1521 let val = &args[idx + 1];
1522 assert!(val.ends_with('k'),
1523 "-bufsize value should end with 'k': {val}");
1524 }
1525 }
1526 }
1527
1528 #[test]
1530 fn vbr_first_pass_has_null_output(
1531 job in arb_encode_job_sw_vbr(),
1532 ) {
1533 let passlog = Path::new("/tmp/plog");
1534 if let Ok(args) = build_encode_args(&job, EncodePass::First(passlog)) {
1535 assert!(!has_arg(&args, "-progress"),
1536 "first pass should not have -progress: {args:?}");
1537 assert!(has_pair(&args, "-f", "null"),
1538 "first pass must write to null: {args:?}");
1539 }
1540 }
1541
1542 #[test]
1544 fn svtav1_qp_disables_aq(
1545 crf in 1i32..63i32,
1546 preset in ".*",
1547 ) {
1548 let job = EncodeJob {
1549 codec: Codec::SvtAv1, crf, rate_control: RateControlMode::Qp,
1550 preset, resolution: None, extra_args: vec![],
1551 ..sample_job(RateControlMode::Qp)
1552 };
1553 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1554 assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"),
1555 "SVT-AV1 QP must disable adaptive quantization: {args:?}");
1556 }
1557
1558 #[test]
1560 fn nvenc_crf_uses_cq_not_crf(
1561 crf in 0i32..63i32,
1562 h264_h265 in prop_oneof![Just(Codec::NvencH264), Just(Codec::NvencH265)],
1563 ) {
1564 let job = EncodeJob {
1565 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1566 preset: String::new(), resolution: None, extra_args: vec![],
1567 ..sample_job(RateControlMode::Crf)
1568 };
1569 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1570 assert!(has_pair(&args, "-rc", "constqp"),
1571 "NVENC CRF missing -rc constqp: {args:?}");
1572 assert!(has_arg(&args, "-cq"),
1573 "NVENC CRF missing -cq: {args:?}");
1574 assert!(!has_arg(&args, "-crf"),
1575 "NVENC must not use -crf: {args:?}");
1576 }
1577
1578 #[test]
1580 fn qsv_crf_uses_global_quality(
1581 crf in 0i32..63i32,
1582 h264_h265 in prop_oneof![Just(Codec::QsvH264), Just(Codec::QsvH265)],
1583 ) {
1584 let job = EncodeJob {
1585 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1586 preset: String::new(), resolution: None, extra_args: vec![],
1587 ..sample_job(RateControlMode::Crf)
1588 };
1589 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1590 assert!(has_arg(&args, "-global_quality"),
1591 "QSV CRF missing -global_quality: {args:?}");
1592 assert!(!has_arg(&args, "-crf"),
1593 "QSV must not use -crf: {args:?}");
1594 }
1595
1596 #[test]
1598 fn amf_crf_uses_qp_pairs(
1599 crf in 0i32..63i32,
1600 h264_h265 in prop_oneof![Just(Codec::AmfH264), Just(Codec::AmfH265)],
1601 ) {
1602 let job = EncodeJob {
1603 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1604 preset: String::new(), resolution: None, extra_args: vec![],
1605 ..sample_job(RateControlMode::Crf)
1606 };
1607 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1608 assert!(has_pair(&args, "-qp_i", &crf.to_string()),
1609 "AMF missing -qp_i: {args:?}");
1610 assert!(!has_arg(&args, "-crf"),
1611 "AMF must not use -crf: {args:?}");
1612 }
1613
1614 #[test]
1616 fn videotoolbox_qp_always_rejected(
1617 crf in 0i32..63i32,
1618 h264_h265 in prop_oneof![Just(Codec::VideoToolboxH264), Just(Codec::VideoToolboxH265)],
1619 ) {
1620 let job = EncodeJob {
1621 codec: h264_h265, crf, rate_control: RateControlMode::Qp,
1622 preset: String::new(), resolution: None, extra_args: vec![],
1623 ..sample_job(RateControlMode::Qp)
1624 };
1625 assert!(build_encode_args(&job, EncodePass::Single).is_err(),
1626 "VideoToolbox QP should be rejected");
1627 }
1628
1629 #[test]
1632 fn vbr_single_pass_errors_for_sw_codecs(
1633 target_br in 100.0f64..100000.0f64,
1634 ) {
1635 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1] {
1636 let job = EncodeJob {
1637 codec: *codec, rate_control: RateControlMode::Vbr,
1638 target_bitrate: target_br,
1639 ..sample_job(RateControlMode::Vbr)
1640 };
1641 assert!(build_encode_args(&job, EncodePass::Single).is_err(),
1642 "{codec:?} VBR single-pass should error");
1643 }
1644 }
1645
1646 #[test]
1648 fn hw_vbr_single_pass_is_valid(
1649 target_br in 100.0f64..100000.0f64,
1650 codec in prop_oneof![
1651 Just(Codec::NvencH264), Just(Codec::QsvH264),
1652 Just(Codec::VideoToolboxH264), Just(Codec::VaapiH264), Just(Codec::AmfH264),
1653 ],
1654 ) {
1655 let job = EncodeJob {
1656 codec, rate_control: RateControlMode::Vbr,
1657 target_bitrate: target_br,
1658 resolution: None, preset: String::new(), extra_args: vec![],
1659 ..sample_job(RateControlMode::Vbr)
1660 };
1661 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1662 assert!(has_pair(&args, "-b:v", &format!("{target_br:.0}k")),
1663 "HW VBR single-pass missing -b:v: {args:?}");
1664 }
1665
1666 #[test]
1668 fn single_pass_output_is_file(job in arb_encode_job()) {
1669 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1670 let last = args.last().unwrap();
1671 assert!(!last.starts_with('-'),
1672 "last arg should not be a flag: {last}");
1673 assert!(!last.is_empty(),
1674 "last arg should not be empty");
1675 }
1676 }
1677 }
1678
1679 fn arb_encode_job_sw_capped() -> impl Strategy<Value = EncodeJob> {
1680 (any::<i32>(), any::<f64>(), any::<f64>(), any::<String>()).prop_map(
1681 |(crf, max_br, bufsize, preset)| {
1682 let crf = crf.abs().min(63);
1683 let max_br = max_br.abs().clamp(100.0, 100000.0);
1684 EncodeJob {
1685 codec: Codec::X264,
1686 crf,
1687 rate_control: RateControlMode::CappedCrf,
1688 max_bitrate: max_br,
1689 bufsize: bufsize.abs().min(200000.0),
1690 preset,
1691 resolution: None,
1692 extra_args: vec![],
1693 ..sample_job(RateControlMode::CappedCrf)
1694 }
1695 },
1696 )
1697 }
1698
1699 fn arb_encode_job_sw_vbr() -> impl Strategy<Value = EncodeJob> {
1700 (any::<i32>(), any::<f64>(), any::<String>()).prop_map(|(crf, target_br, preset)| {
1701 let crf = crf.abs().min(63);
1702 let target_br = target_br.abs().clamp(100.0, 100000.0);
1703 EncodeJob {
1704 codec: Codec::X264,
1705 crf,
1706 rate_control: RateControlMode::Vbr,
1707 target_bitrate: target_br,
1708 preset,
1709 resolution: None,
1710 extra_args: vec![],
1711 ..sample_job(RateControlMode::Vbr)
1712 }
1713 })
1714 }
1715 }
1716}