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