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