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