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