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