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 fn chunk_plan(duration: f64, chunk_seconds: f64) -> Vec<(f64, f64)> {
217 if duration <= 0.0 || chunk_seconds <= 0.0 {
218 return vec![];
219 }
220 let mut start = 0.0;
221 let mut chunks = Vec::new();
222 while start < duration {
223 let remaining = duration - start;
224 let cd = remaining.min(chunk_seconds);
225 chunks.push((start, cd));
226 start += cd;
227 }
228 chunks
229}
230
231pub async fn chunked_encode(
240 job: EncodeJob,
241 chunk_seconds: f64,
242 parallel: usize,
243) -> anyhow::Result<EncodeResult> {
244 let probe_result = probe(&job.input).await?;
245 let duration = probe_result.format.duration;
246 if duration <= 0.0 {
247 anyhow::bail!("could not determine source duration for chunked encoding");
248 }
249 let chunks = chunk_plan(duration, chunk_seconds);
250 if chunks.is_empty() {
251 anyhow::bail!(
252 "no chunks were planned (duration={duration}, chunk_seconds={chunk_seconds})"
253 );
254 }
255 if chunks.len() == 1 {
256 return encode(job, None).await;
258 }
259
260 let tmp_dir = tempfile::Builder::new().prefix("viser-chunked-").tempdir()?;
261 let parallel = parallel.max(1);
262
263 let outputs = std::sync::Arc::new(std::sync::Mutex::new(vec![None; chunks.len()]));
265 let errors = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
266
267 let sem = std::sync::Arc::new(tokio::sync::Semaphore::new(parallel));
269 let mut handles = Vec::with_capacity(chunks.len());
270
271 for (index, (start, duration)) in chunks.iter().copied().enumerate() {
272 let permit = sem.clone().acquire_owned().await?;
273 let chunk_output = tmp_dir.path().join(format!("chunk_{index:04}.mp4"));
274 let output_path = chunk_output.to_string_lossy().into_owned();
275 let chunk_job = EncodeJob {
276 input: job.input.clone(),
277 output: output_path.clone(),
278 extra_args: vec![
279 "-ss".into(),
280 format!("{start:.6}"),
281 "-t".into(),
282 format!("{duration:.6}"),
283 ],
284 ..job.clone()
285 };
286
287 let outputs_clone = outputs.clone();
288 let errors_clone = errors.clone();
289
290 handles.push(tokio::spawn(async move {
291 let _permit = permit;
292 match encode(chunk_job, None).await {
293 Ok(_) => {
294 outputs_clone.lock().unwrap()[index] = Some(output_path);
295 }
296 Err(e) => {
297 errors_clone.lock().unwrap().push((index, e));
298 }
299 }
300 }));
301 }
302
303 for h in handles {
305 let _ = h.await;
306 }
307
308 {
310 let errs = errors.lock().unwrap();
311 if !errs.is_empty() {
312 let msg =
313 errs.iter().map(|(i, e)| format!("chunk {i}: {e}")).collect::<Vec<_>>().join("; ");
314 anyhow::bail!("chunked encode failed: {msg}");
315 }
316 }
317
318 let outputs: Vec<String> = {
320 let guard = outputs.lock().unwrap();
321 guard
322 .iter()
323 .enumerate()
324 .map(|(i, opt)| {
325 opt.clone().ok_or_else(|| anyhow::anyhow!("chunk {i} produced no output"))
326 })
327 .collect::<anyhow::Result<_>>()?
328 };
329
330 let started = std::time::Instant::now();
331 concat(&outputs, &job.output).await?;
332
333 let meta = std::fs::metadata(&job.output)?;
334 let output_probe = probe(&job.output).await?;
335 let bitrate = output_probe.format.bit_rate as f64 / 1000.0;
336
337 Ok(EncodeResult { job, bitrate, file_size: meta.len(), duration: started.elapsed() })
338}
339
340pub async fn concat(inputs: &[String], output: &str) -> anyhow::Result<()> {
342 if inputs.is_empty() {
343 anyhow::bail!("cannot concat an empty input list");
344 }
345
346 let list_path = make_concat_list_path(output);
347 let list_body = inputs
348 .iter()
349 .map(|path| format!("file '{}'", escape_concat_path(path)))
350 .collect::<Vec<_>>()
351 .join("\n");
352 std::fs::write(&list_path, format!("{list_body}\n"))?;
353
354 let args = vec![
355 "-y".to_string(),
356 "-f".into(),
357 "concat".into(),
358 "-safe".into(),
359 "0".into(),
360 "-i".into(),
361 list_path.to_string_lossy().into_owned(),
362 "-c".into(),
363 "copy".into(),
364 output.into(),
365 ];
366
367 let result = run_ffmpeg(args, None).await;
368 let _ = std::fs::remove_file(&list_path);
369 result
370}
371
372enum EncodePass<'a> {
373 Single,
374 First(&'a Path),
375 Second(&'a Path),
376}
377
378fn build_encode_args(job: &EncodeJob, pass: EncodePass<'_>) -> anyhow::Result<Vec<String>> {
379 let mut args = vec!["-y".into()];
380
381 if let Some(accel) = job.hwaccel.as_deref().filter(|a| !a.is_empty()) {
386 args.extend(["-hwaccel".into(), accel.into()]);
387 }
388 if job.codec.backend() == EncoderBackend::Vaapi {
391 args.extend(["-vaapi_device".into(), vaapi_device()]);
392 }
393
394 args.extend(["-i".into(), job.input.clone(), "-an".into()]);
395
396 if !matches!(pass, EncodePass::First(_)) {
397 args.extend(["-progress".into(), "pipe:1".into(), "-nostats".into()]);
398 }
399
400 args.extend(["-c:v".into(), job.codec.as_str().into()]);
401
402 if job.codec.is_hardware() {
403 build_hw_args(&mut args, job, &pass)?;
404 } else {
405 build_sw_args(&mut args, job, &pass)?;
406 }
407
408 if !job.preset.is_empty() {
409 if job.codec.is_hardware() {
410 add_hw_preset(&mut args, job.codec, &job.preset);
411 } else if job.codec == Codec::Vp9 {
412 add_vp9_preset(&mut args, &job.preset);
413 } else {
414 args.extend(["-preset".into(), job.preset.clone()]);
415 }
416 }
417
418 if let Some(format) = &job.source_format {
419 args.extend(encode_color_args(job.codec, format));
420 }
421
422 if let Some(vf) = build_filter_chain(job) {
423 args.extend(["-vf".into(), vf]);
424 }
425
426 args.extend(job.extra_args.iter().cloned());
427
428 if job.codec == Codec::SvtAv1 {
431 coalesce_repeated_flag(&mut args, "-svtav1-params", ":");
432 }
433
434 match pass {
435 EncodePass::First(_) => {
436 args.extend(["-f".into(), "null".into()]);
437 args.push(null_output_path().into());
438 }
439 EncodePass::Single | EncodePass::Second(_) => args.push(job.output.clone()),
440 }
441
442 Ok(args)
443}
444
445fn coalesce_repeated_flag(args: &mut Vec<String>, flag: &str, sep: &str) {
452 let positions: Vec<usize> =
453 (0..args.len().saturating_sub(1)).filter(|&i| args[i] == flag).collect();
454 if positions.len() < 2 {
455 return;
456 }
457 let merged = positions.iter().map(|&i| args[i + 1].clone()).collect::<Vec<_>>().join(sep);
458 let drop: std::collections::HashSet<usize> =
461 positions.iter().skip(1).flat_map(|&i| [i, i + 1]).collect();
462 let first_value = positions[0] + 1;
463 let mut out = Vec::with_capacity(args.len());
464 for (i, a) in args.drain(..).enumerate() {
465 if drop.contains(&i) {
466 continue;
467 }
468 out.push(if i == first_value { merged.clone() } else { a });
469 }
470 *args = out;
471}
472
473fn vaapi_device() -> String {
476 std::env::var("VISER_VAAPI_DEVICE").unwrap_or_else(|_| "/dev/dri/renderD128".to_string())
477}
478
479fn build_filter_chain(job: &EncodeJob) -> Option<String> {
487 let scale = job
488 .resolution
489 .filter(|res| res.width > 0 && res.height > 0)
490 .map(|res| format!("scale={}:{}:flags=lanczos", res.width, res.height));
491
492 if job.codec.backend() == EncoderBackend::Vaapi {
493 let va_fmt = vaapi_surface_format(job);
495 Some(match scale {
496 Some(s) => format!("{s},format={va_fmt},hwupload"),
497 None => format!("format={va_fmt},hwupload"),
498 })
499 } else {
500 scale
501 }
502}
503
504fn vaapi_surface_format(job: &EncodeJob) -> &'static str {
508 job.source_format
509 .as_ref()
510 .filter(|fmt| fmt.is_high_bit_depth() || fmt.is_hdr)
511 .map_or("nv12", |_| "p010")
512}
513
514fn build_sw_args(
515 args: &mut Vec<String>,
516 job: &EncodeJob,
517 pass: &EncodePass<'_>,
518) -> anyhow::Result<()> {
519 match job.rate_control {
520 RateControlMode::Qp => {
521 if job.codec == Codec::SvtAv1 {
522 args.extend(["-qp".into(), job.crf.to_string()]);
523 args.extend(["-svtav1-params".into(), "enable-adaptive-quantization=0".into()]);
524 } else {
525 args.extend(["-qp".into(), job.crf.to_string()]);
526 }
527 }
528 RateControlMode::CappedCrf => {
529 if job.max_bitrate <= 0.0 {
530 anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
531 }
532 args.extend(["-crf".into(), job.crf.to_string()]);
533 if job.codec == Codec::Vp9 {
534 args.extend(["-b:v".into(), format!("{:.0}k", job.max_bitrate)]);
536 args.extend(["-deadline".into(), "good".into()]);
537 } else {
538 let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
539 args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
540 args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
541 }
542 }
543 RateControlMode::Vbr => {
544 if job.target_bitrate <= 0.0 {
545 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
546 }
547 args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
548 args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
549 args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
550
551 let passlog = match pass {
552 EncodePass::First(path) => {
553 args.extend(["-pass".into(), "1".into()]);
554 path
555 }
556 EncodePass::Second(path) => {
557 args.extend(["-pass".into(), "2".into()]);
558 path
559 }
560 EncodePass::Single => {
561 anyhow::bail!("VBR mode requires a two-pass encode flow");
562 }
563 };
564 args.extend(["-passlogfile".into(), passlog.to_string_lossy().into_owned()]);
565 }
566 RateControlMode::Crf => {
567 args.extend(["-crf".into(), job.crf.to_string()]);
568 }
569 }
570 Ok(())
571}
572
573fn build_hw_args(
574 args: &mut Vec<String>,
575 job: &EncodeJob,
576 _pass: &EncodePass<'_>,
577) -> anyhow::Result<()> {
578 let backend = job.codec.backend();
579 match job.rate_control {
580 RateControlMode::Crf | RateControlMode::CappedCrf => {
581 match backend {
582 EncoderBackend::Nvenc => {
583 let cq = crf_to_nvenc_cq(job.crf);
584 args.extend(["-cq".into(), cq.to_string()]);
585 let rc = if matches!(job.rate_control, RateControlMode::CappedCrf) {
588 "vbr"
589 } else {
590 "constqp"
591 };
592 args.extend(["-rc".into(), rc.into()]);
593 }
594 EncoderBackend::Qsv => {
595 let gq = crf_to_qsv_quality(job.crf);
596 args.extend(["-global_quality".into(), gq.to_string()]);
597 }
598 EncoderBackend::VideoToolbox => {
599 let qual = crf_to_vt_quality(job.crf);
600 args.extend(["-quality".into(), qual.to_string()]);
601 }
602 EncoderBackend::Vaapi => {
603 let gq = crf_to_qsv_quality(job.crf);
604 args.extend(["-global_quality".into(), gq.to_string()]);
605 }
606 EncoderBackend::Amf => {
607 args.extend(["-qp_i".into(), job.crf.to_string()]);
608 args.extend(["-qp_p".into(), (job.crf + 2).to_string()]);
609 args.extend(["-usage".into(), "transcoding".into()]);
610 }
611 EncoderBackend::Software => unreachable!(),
612 }
613 if let RateControlMode::CappedCrf = job.rate_control {
615 if job.max_bitrate <= 0.0 {
616 anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
617 }
618 let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
619 args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
620 args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
621 }
622 }
623 RateControlMode::Qp => match backend {
624 EncoderBackend::VideoToolbox => {
625 anyhow::bail!("VideoToolbox does not support QP rate control mode");
626 }
627 _ => {
628 args.extend(["-qp".into(), job.crf.to_string()]);
629 }
630 },
631 RateControlMode::Vbr => {
632 if job.target_bitrate <= 0.0 {
633 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
634 }
635 args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
636 args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
637 args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
638
639 if backend == EncoderBackend::Nvenc {
640 args.extend(["-rc".into(), "vbr_hq".into()]);
641 }
642 }
643 }
644 Ok(())
645}
646
647fn crf_to_nvenc_cq(crf: i32) -> i32 {
648 let cq = (crf * 51) / 63;
649 cq.clamp(1, 51)
650}
651
652fn crf_to_qsv_quality(crf: i32) -> i32 {
653 let gq = 100 - ((crf * 100) / 51);
654 gq.clamp(1, 100)
655}
656
657fn crf_to_vt_quality(crf: i32) -> f64 {
658 let q = 1.0 - (crf as f64 / 51.0);
659 q.clamp(0.0, 1.0)
660}
661
662fn add_vp9_preset(args: &mut Vec<String>, preset: &str) {
663 args.extend(["-cpu-used".into(), map_vp9_cpu_used(preset).into()]);
664 args.extend(["-deadline".into(), "good".into()]);
665 args.extend(["-row-mt".into(), "1".into()]);
666}
667
668fn map_vp9_cpu_used(preset: &str) -> &str {
669 match preset {
670 "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" => preset,
671 "ultrafast" | "superfast" => "8",
672 "veryfast" => "6",
673 "faster" => "5",
674 "fast" => "4",
675 "medium" => "2",
676 "slow" => "1",
677 "slower" | "veryslow" => "0",
678 other => other,
679 }
680}
681
682fn add_hw_preset(args: &mut Vec<String>, codec: Codec, preset: &str) {
683 match codec.backend() {
684 EncoderBackend::Nvenc => {
685 let p = map_nvenc_preset(preset);
686 args.extend(["-preset".into(), p.into()]);
687 }
688 EncoderBackend::Qsv => {
689 args.extend(["-preset".into(), preset.to_string()]);
690 }
691 EncoderBackend::Vaapi => {
692 args.extend(["-compression_level".into(), map_vaapi_preset(preset).into()]);
693 }
694 EncoderBackend::Amf => {
695 args.extend(["-quality".into(), map_amf_quality(preset).into()]);
696 }
697 EncoderBackend::VideoToolbox => {
698 if preset == "ultrafast" || preset == "superfast" || preset == "veryfast" {
699 args.extend(["-realtime".into(), "1".into()]);
700 }
701 }
702 EncoderBackend::Software => unreachable!(),
703 }
704}
705
706fn map_nvenc_preset(preset: &str) -> &str {
707 match preset {
708 "ultrafast" | "superfast" => "p1",
709 "veryfast" => "p2",
710 "faster" => "p3",
711 "fast" => "p4",
712 "medium" => "p5",
713 "slow" => "p6",
714 "slower" | "veryslow" => "p7",
715 other => other,
716 }
717}
718
719fn map_vaapi_preset(preset: &str) -> &str {
720 match preset {
721 "ultrafast" | "superfast" => "1",
722 "veryfast" | "faster" => "2",
723 "fast" | "medium" => "3",
724 "slow" => "4",
725 "slower" | "veryslow" => "5",
726 other => other,
727 }
728}
729
730fn map_amf_quality(preset: &str) -> &str {
731 match preset {
732 "ultrafast" | "superfast" => "speed",
733 "veryfast" | "faster" | "fast" => "balanced",
734 "medium" | "slow" | "slower" | "veryslow" => "quality",
735 other => other,
736 }
737}
738
739fn escape_concat_path(path: &str) -> String {
743 path.replace('\\', "\\\\").replace('\'', "\\'")
744}
745
746fn make_passlog_prefix(output: &str) -> PathBuf {
747 let output_path = Path::new(output);
748 let parent =
749 output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
750 let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
751 let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
752 parent.join(format!(".{stem}.viser-passlog-{unique}-{}", std::process::id()))
753}
754
755fn make_concat_list_path(output: &str) -> PathBuf {
756 let output_path = Path::new(output);
757 let parent =
758 output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
759 let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
760 let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
761 parent.join(format!(".{stem}.viser-concat-{unique}-{}.txt", std::process::id()))
762}
763
764fn null_output_path() -> &'static str {
765 if cfg!(windows) { "NUL" } else { "/dev/null" }
766}
767
768struct PasslogCleanup {
769 parent: PathBuf,
770 prefix: String,
771}
772
773impl PasslogCleanup {
774 fn new(path: PathBuf) -> Self {
775 let parent = path.parent().unwrap_or(Path::new(".")).to_path_buf();
776 let prefix = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_owned();
777 Self { parent, prefix }
778 }
779
780 fn run(&self) {
781 let Ok(entries) = std::fs::read_dir(&self.parent) else {
782 return;
783 };
784
785 for entry in entries.flatten() {
786 let path = entry.path();
787 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
788 continue;
789 };
790 if !name.starts_with(&self.prefix) {
791 continue;
792 }
793 if let Err(err) = std::fs::remove_file(&path) {
794 tracing::debug!(?path, ?err, "failed to remove ffmpeg two-pass log file");
795 }
796 }
797 }
798}
799
800impl Drop for PasslogCleanup {
801 fn drop(&mut self) {
802 self.run();
803 }
804}
805
806fn parse_progress_line(line: &str, p: &mut Progress) -> bool {
808 let Some((key, value)) = line.split_once('=') else {
809 return false;
810 };
811
812 match key {
813 "frame" => {
814 p.frame = value.parse().unwrap_or(0);
815 }
816 "fps" => {
817 p.fps = value.parse().unwrap_or(0.0);
818 }
819 "bitrate" => {
820 let v = value.trim_end_matches("kbits/s");
821 p.bitrate = v.parse().unwrap_or(0.0);
822 }
823 "speed" => {
824 let v = value.trim_end_matches('x');
825 p.speed = v.parse().unwrap_or(0.0);
826 }
827 "out_time_us" => {
828 let us: u64 = value.parse().unwrap_or(0);
829 p.time = Duration::from_micros(us);
830 }
831 "progress" => return true,
832 _ => {}
833 }
834 false
835}
836
837#[cfg(test)]
838mod tests {
839 use super::*;
840 use crate::Codec;
841
842 fn sample_job(mode: RateControlMode) -> EncodeJob {
843 EncodeJob {
844 input: "input.mp4".into(),
845 output: "output.mp4".into(),
846 resolution: Some(crate::Resolution::new(1280, 720)),
847 codec: Codec::X264,
848 crf: 23,
849 rate_control: mode,
850 target_bitrate: 2500.0,
851 max_bitrate: 3000.0,
852 bufsize: 6000.0,
853 preset: "medium".into(),
854 hwaccel: None,
855 extra_args: vec![],
856 source_format: None,
857 }
858 }
859
860 fn job_with_codec(codec: Codec, mode: RateControlMode) -> EncodeJob {
861 EncodeJob { codec, rate_control: mode, ..sample_job(mode) }
862 }
863
864 fn has_pair(args: &[String], a: &str, b: &str) -> bool {
866 args.windows(2).any(|w| w[0] == a && w[1] == b)
867 }
868
869 fn has_arg(args: &[String], a: &str) -> bool {
870 args.iter().any(|s| s == a)
871 }
872
873 fn hdr10_svtav1_format() -> SourceFormat {
874 use crate::{Hdr10Metadata, MasteringDisplay};
875 SourceFormat {
876 pix_fmt: "yuv420p10le".into(),
877 bit_depth: 10,
878 color_primaries: "bt2020".into(),
879 color_transfer: "smpte2084".into(),
880 color_space: "bt2020nc".into(),
881 is_hdr: true,
882 hdr10: Some(Hdr10Metadata {
883 mastering_display: Some(MasteringDisplay {
884 green_x: 13250,
885 green_y: 34500,
886 blue_x: 7500,
887 blue_y: 3000,
888 red_x: 34000,
889 red_y: 16000,
890 white_x: 15635,
891 white_y: 16450,
892 max_luminance: 10_000_000,
893 min_luminance: 50,
894 }),
895 max_cll: Some(1000),
896 max_fall: Some(400),
897 }),
898 }
899 }
900
901 #[test]
902 fn test_coalesce_repeated_flag_merges_values() {
903 let mut args = vec![
904 "-i".into(),
905 "in.mp4".into(),
906 "-svtav1-params".into(),
907 "enable-adaptive-quantization=0".into(),
908 "-pix_fmt".into(),
909 "yuv420p10le".into(),
910 "-svtav1-params".into(),
911 "mastering-display=X:content-light=1000,400".into(),
912 "out.mp4".into(),
913 ];
914 coalesce_repeated_flag(&mut args, "-svtav1-params", ":");
915 assert_eq!(args.iter().filter(|a| *a == "-svtav1-params").count(), 1);
917 let merged =
918 args.windows(2).find(|w| w[0] == "-svtav1-params").map(|w| w[1].clone()).unwrap();
919 assert_eq!(
920 merged,
921 "enable-adaptive-quantization=0:mastering-display=X:content-light=1000,400"
922 );
923 assert!(has_pair(&args, "-pix_fmt", "yuv420p10le"));
925 assert_eq!(args.last().unwrap(), "out.mp4");
926 }
927
928 #[test]
929 fn test_coalesce_repeated_flag_noop_when_single() {
930 let mut args = vec!["-svtav1-params".into(), "a=1".into(), "out.mp4".into()];
931 let before = args.clone();
932 coalesce_repeated_flag(&mut args, "-svtav1-params", ":");
933 assert_eq!(args, before);
934 }
935
936 #[test]
937 fn test_build_encode_args_svtav1_qp_hdr_single_params() {
938 let mut job = job_with_codec(Codec::SvtAv1, RateControlMode::Qp);
939 job.resolution = None;
940 job.source_format = Some(hdr10_svtav1_format());
941 let args = build_encode_args(&job, EncodePass::Single).unwrap();
942 assert_eq!(args.iter().filter(|a| *a == "-svtav1-params").count(), 1);
944 let params = args
945 .windows(2)
946 .find(|w| w[0] == "-svtav1-params")
947 .map(|w| w[1].clone())
948 .expect("svtav1-params present");
949 assert!(params.contains("enable-adaptive-quantization=0"), "got: {params}");
950 assert!(params.contains("mastering-display=G(0.265,0.69)"), "got: {params}");
951 assert!(params.contains("content-light=1000,400"), "got: {params}");
952 }
953
954 #[test]
956 fn test_build_encode_args_preserves_10bit_source() {
957 let mut job = sample_job(RateControlMode::Crf);
958 job.codec = Codec::X265;
959 job.source_format = Some(SourceFormat {
960 pix_fmt: "yuv420p10le".into(),
961 bit_depth: 10,
962 color_primaries: "bt709".into(),
963 color_transfer: "bt709".into(),
964 color_space: "bt709".into(),
965 is_hdr: false,
966 hdr10: None,
967 });
968 let args = build_encode_args(&job, EncodePass::Single).unwrap();
969 assert!(has_pair(&args, "-pix_fmt", "yuv420p10le"));
970 assert!(args.iter().any(|a| a.contains("profile=main10")));
971 }
972
973 #[test]
974 fn test_build_encode_args_crf_single_pass() {
975 let args =
976 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
977 assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
978 assert_eq!(args.last().unwrap(), "output.mp4");
979 }
980
981 #[test]
982 fn test_x264_crf_args() {
983 let args = build_encode_args(
984 &job_with_codec(Codec::X264, RateControlMode::Crf),
985 EncodePass::Single,
986 )
987 .unwrap();
988 assert!(has_pair(&args, "-c:v", "libx264"));
989 assert!(has_pair(&args, "-crf", "23"));
990 assert!(has_pair(&args, "-preset", "medium"));
991 }
992
993 #[test]
994 fn test_x265_crf_args() {
995 let args = build_encode_args(
996 &job_with_codec(Codec::X265, RateControlMode::Crf),
997 EncodePass::Single,
998 )
999 .unwrap();
1000 assert!(has_pair(&args, "-c:v", "libx265"));
1001 assert!(has_pair(&args, "-crf", "23"));
1002 }
1003
1004 #[test]
1005 fn test_svtav1_crf_args() {
1006 let args = build_encode_args(
1007 &job_with_codec(Codec::SvtAv1, RateControlMode::Crf),
1008 EncodePass::Single,
1009 )
1010 .unwrap();
1011 assert!(has_pair(&args, "-c:v", "libsvtav1"));
1012 assert!(has_pair(&args, "-crf", "23"));
1013 }
1014
1015 #[test]
1016 fn test_vp9_crf_args() {
1017 let args = build_encode_args(
1018 &job_with_codec(Codec::Vp9, RateControlMode::Crf),
1019 EncodePass::Single,
1020 )
1021 .unwrap();
1022 assert!(has_pair(&args, "-c:v", "libvpx-vp9"));
1023 assert!(has_pair(&args, "-crf", "23"));
1024 assert!(has_pair(&args, "-cpu-used", "2"));
1025 assert!(has_pair(&args, "-deadline", "good"));
1026 assert!(has_pair(&args, "-row-mt", "1"));
1027 assert!(!has_arg(&args, "-preset"));
1028 }
1029
1030 #[test]
1031 fn test_vp9_capped_crf_uses_constrained_quality() {
1032 let mut job = job_with_codec(Codec::Vp9, RateControlMode::CappedCrf);
1033 job.max_bitrate = 2000.0;
1034 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1035 assert!(has_pair(&args, "-crf", "23"));
1036 assert!(has_pair(&args, "-b:v", "2000k"));
1037 assert!(has_pair(&args, "-deadline", "good"));
1038 assert!(!has_arg(&args, "-maxrate"));
1039 }
1040
1041 #[test]
1043 fn test_x264_qp_args() {
1044 let args = build_encode_args(
1045 &job_with_codec(Codec::X264, RateControlMode::Qp),
1046 EncodePass::Single,
1047 )
1048 .unwrap();
1049 assert!(has_pair(&args, "-qp", "23"));
1050 assert!(!has_arg(&args, "-crf"));
1051 }
1052
1053 #[test]
1054 fn test_x265_qp_args() {
1055 let args = build_encode_args(
1056 &job_with_codec(Codec::X265, RateControlMode::Qp),
1057 EncodePass::Single,
1058 )
1059 .unwrap();
1060 assert!(has_pair(&args, "-qp", "23"));
1061 }
1062
1063 #[test]
1064 fn test_svtav1_qp_adds_adaptive_quantization_off() {
1065 let args = build_encode_args(
1066 &job_with_codec(Codec::SvtAv1, RateControlMode::Qp),
1067 EncodePass::Single,
1068 )
1069 .unwrap();
1070 assert!(has_pair(&args, "-qp", "23"));
1071 assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"));
1072 }
1073
1074 #[test]
1076 fn test_build_encode_args_capped_crf_sets_vbv() {
1077 let args =
1078 build_encode_args(&sample_job(RateControlMode::CappedCrf), EncodePass::Single).unwrap();
1079 assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
1080 assert!(args.windows(2).any(|w| w == ["-maxrate", "3000k"]));
1081 assert!(args.windows(2).any(|w| w == ["-bufsize", "6000k"]));
1082 }
1083
1084 #[test]
1085 fn test_capped_crf_max_bitrate_zero_errors() {
1086 let job = EncodeJob {
1087 max_bitrate: 0.0,
1088 rate_control: RateControlMode::CappedCrf,
1089 ..sample_job(RateControlMode::CappedCrf)
1090 };
1091 assert!(build_encode_args(&job, EncodePass::Single).is_err());
1092 }
1093
1094 #[test]
1095 fn test_capped_crf_max_bitrate_negative_errors() {
1096 let job = EncodeJob {
1097 max_bitrate: -1.0,
1098 rate_control: RateControlMode::CappedCrf,
1099 ..sample_job(RateControlMode::CappedCrf)
1100 };
1101 assert!(build_encode_args(&job, EncodePass::Single).is_err());
1102 }
1103
1104 #[test]
1105 fn test_capped_crf_auto_bufsize_when_zero() {
1106 let job = EncodeJob {
1107 max_bitrate: 4000.0,
1108 bufsize: 0.0,
1109 rate_control: RateControlMode::CappedCrf,
1110 ..sample_job(RateControlMode::CappedCrf)
1111 };
1112 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1113 assert!(has_pair(&args, "-bufsize", "8000k"));
1114 }
1115
1116 #[test]
1118 fn test_vbr_target_bitrate_zero_errors() {
1119 let job = EncodeJob {
1120 target_bitrate: 0.0,
1121 rate_control: RateControlMode::Vbr,
1122 ..sample_job(RateControlMode::Vbr)
1123 };
1124 assert!(build_encode_args(&job, EncodePass::First(Path::new("passlog"))).is_err());
1125 }
1126
1127 #[test]
1128 fn test_vbr_single_pass_errors() {
1129 assert!(build_encode_args(&sample_job(RateControlMode::Vbr), EncodePass::Single).is_err());
1130 }
1131
1132 #[test]
1133 fn test_build_encode_args_vbr_first_pass_uses_null_output() {
1134 let job = sample_job(RateControlMode::Vbr);
1135 let passlog = Path::new("/tmp/viser-passlog");
1136 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
1137 assert!(args.windows(2).any(|w| w == ["-pass", "1"]));
1138 assert!(args.windows(2).any(|w| w == ["-f", "null"]));
1139 assert_eq!(args.last().unwrap(), null_output_path());
1140 }
1141
1142 #[test]
1143 fn test_build_encode_args_vbr_second_pass_writes_output() {
1144 let job = sample_job(RateControlMode::Vbr);
1145 let passlog = Path::new("/tmp/viser-passlog");
1146 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1147 assert!(args.windows(2).any(|w| w == ["-pass", "2"]));
1148 assert_eq!(args.last().unwrap(), "output.mp4");
1149 }
1150
1151 #[test]
1152 fn test_vbr_first_pass_no_progress_args() {
1153 let job = sample_job(RateControlMode::Vbr);
1154 let passlog = Path::new("/tmp/viser-passlog");
1155 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
1156 assert!(!has_arg(&args, "-progress"));
1157 assert!(!has_arg(&args, "-nostats"));
1158 }
1159
1160 #[test]
1161 fn test_vbr_second_pass_sets_bitrate_and_vbv() {
1162 let job = sample_job(RateControlMode::Vbr);
1163 let passlog = Path::new("/tmp/viser-passlog");
1164 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1165 assert!(has_pair(&args, "-b:v", "2500k"));
1166 assert!(has_pair(&args, "-maxrate", "5000k"));
1167 assert!(has_pair(&args, "-bufsize", "10000k"));
1168 }
1169
1170 #[test]
1171 fn test_vbr_sets_passlog() {
1172 let job = sample_job(RateControlMode::Vbr);
1173 let passlog = Path::new("/tmp/viser-passlog");
1174 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
1175 assert!(has_arg(&args, "/tmp/viser-passlog"));
1176 }
1177
1178 #[test]
1180 fn test_resolution_scaling_adds_vf() {
1181 let args =
1182 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1183 assert!(has_arg(&args, "-vf"));
1184 assert!(has_arg(&args, "scale=1280:720:flags=lanczos"));
1185 }
1186
1187 #[test]
1188 fn test_zero_width_skips_scale() {
1189 let job = EncodeJob {
1190 resolution: Some(crate::Resolution::new(0, 720)),
1191 ..sample_job(RateControlMode::Crf)
1192 };
1193 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1194 assert!(!has_arg(&args, "-vf"));
1195 }
1196
1197 #[test]
1198 fn test_zero_height_skips_scale() {
1199 let job = EncodeJob {
1200 resolution: Some(crate::Resolution::new(1280, 0)),
1201 ..sample_job(RateControlMode::Crf)
1202 };
1203 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1204 assert!(!has_arg(&args, "-vf"));
1205 }
1206
1207 #[test]
1208 fn test_no_resolution_skips_scale() {
1209 let job = EncodeJob { resolution: None, ..sample_job(RateControlMode::Crf) };
1210 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1211 assert!(!has_arg(&args, "-vf"));
1212 }
1213
1214 #[test]
1215 fn test_resolution_negative_skip_scale() {
1216 let job = EncodeJob {
1217 resolution: Some(crate::Resolution::new(-1, -1)),
1218 ..sample_job(RateControlMode::Crf)
1219 };
1220 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1221 assert!(!has_arg(&args, "-vf"));
1222 }
1223
1224 #[test]
1226 fn test_empty_preset_no_preset_arg() {
1227 let job = EncodeJob { preset: String::new(), ..sample_job(RateControlMode::Crf) };
1228 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1229 assert!(!has_arg(&args, "-preset"));
1230 }
1231
1232 #[test]
1233 fn test_preset_with_x264() {
1234 let job = EncodeJob {
1235 codec: Codec::X264,
1236 preset: "fast".into(),
1237 ..sample_job(RateControlMode::Crf)
1238 };
1239 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1240 assert!(has_pair(&args, "-preset", "fast"));
1241 }
1242
1243 #[test]
1245 fn test_extra_args_appended_before_output() {
1246 let job = EncodeJob {
1247 extra_args: vec!["-g".into(), "30".into(), "-bf".into(), "2".into()],
1248 ..sample_job(RateControlMode::Crf)
1249 };
1250 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1251 assert!(has_pair(&args, "-g", "30"));
1252 assert!(has_pair(&args, "-bf", "2"));
1253 assert_eq!(args.last().unwrap(), "output.mp4");
1254 }
1255
1256 #[test]
1259 fn test_chunk_plan_single_chunk() {
1260 let chunks = chunk_plan(30.0, 60.0);
1261 assert_eq!(chunks.len(), 1);
1262 assert_eq!(chunks[0], (0.0, 30.0));
1263 }
1264
1265 #[test]
1266 fn test_chunk_plan_exact_multiple() {
1267 let chunks = chunk_plan(60.0, 30.0);
1268 assert_eq!(chunks.len(), 2);
1269 assert_eq!(chunks[0], (0.0, 30.0));
1270 assert_eq!(chunks[1], (30.0, 30.0));
1271 }
1272
1273 #[test]
1274 fn test_chunk_plan_uneven_final() {
1275 let chunks = chunk_plan(100.0, 30.0);
1276 assert_eq!(chunks.len(), 4);
1277 assert_eq!(chunks[0], (0.0, 30.0));
1278 assert_eq!(chunks[1], (30.0, 30.0));
1279 assert_eq!(chunks[2], (60.0, 30.0));
1280 assert_eq!(chunks[3], (90.0, 10.0));
1281 }
1282
1283 #[test]
1284 fn test_chunk_plan_negative_duration() {
1285 assert!(chunk_plan(-1.0, 30.0).is_empty());
1286 }
1287
1288 #[test]
1289 fn test_chunk_plan_zero_chunk_seconds() {
1290 assert!(chunk_plan(100.0, 0.0).is_empty());
1291 }
1292
1293 #[test]
1294 fn test_chunk_plan_very_small_chunks() {
1295 let chunks = chunk_plan(5.0, 2.0);
1296 assert_eq!(chunks.len(), 3);
1297 assert_eq!(chunks[0], (0.0, 2.0));
1298 assert_eq!(chunks[1], (2.0, 2.0));
1299 assert_eq!(chunks[2], (4.0, 1.0));
1300 }
1301
1302 #[tokio::test]
1307 async fn test_chunked_encode_fails_on_unknown_input() {
1308 let job = EncodeJob {
1309 input: "/nonexistent/input.mp4".into(),
1310 output: "out.mp4".into(),
1311 ..sample_job(RateControlMode::Crf)
1312 };
1313 let result = chunked_encode(job, 30.0, 2).await;
1314 assert!(result.is_err(), "expected error for non-existent input");
1315 }
1316
1317 #[tokio::test]
1318 async fn test_chunked_encode_single_chunk_delegates_to_encode() {
1319 let job = EncodeJob {
1323 input: "/nonexistent/input.mp4".into(),
1324 output: "out.mp4".into(),
1325 resolution: None,
1326 codec: Codec::X264,
1327 crf: 23,
1328 rate_control: RateControlMode::Crf,
1329 ..sample_job(RateControlMode::Crf)
1330 };
1331 let result = chunked_encode(job, 999999.0, 2).await;
1332 assert!(result.is_err());
1335 let err = result.unwrap_err().to_string();
1336 assert!(err.contains("ffmpeg") || err.contains("probe") || err.contains("encode"));
1337 }
1338
1339 #[test]
1340 fn test_null_output_path_is_platform_appropriate() {
1341 let null = null_output_path();
1342 assert!(!null.is_empty());
1343 assert!(null == "/dev/null" || null == "NUL");
1344 }
1345
1346 #[test]
1348 fn test_parse_progress_line_frame() {
1349 let mut p = Progress::default();
1350 assert!(!parse_progress_line("frame=100", &mut p));
1351 assert_eq!(p.frame, 100);
1352 }
1353
1354 #[test]
1355 fn test_parse_progress_line_fps() {
1356 let mut p = Progress::default();
1357 parse_progress_line("fps=23.976", &mut p);
1358 assert!((p.fps - 23.976).abs() < 0.001);
1359 }
1360
1361 #[test]
1362 fn test_parse_progress_line_bitrate() {
1363 let mut p = Progress::default();
1364 parse_progress_line("bitrate=1500.5kbits/s", &mut p);
1365 assert!((p.bitrate - 1500.5).abs() < 0.001);
1366 }
1367
1368 #[test]
1369 fn test_parse_progress_line_speed() {
1370 let mut p = Progress::default();
1371 parse_progress_line("speed=1.5x", &mut p);
1372 assert!((p.speed - 1.5).abs() < 0.001);
1373 }
1374
1375 #[test]
1376 fn test_parse_progress_line_out_time_us() {
1377 let mut p = Progress::default();
1378 parse_progress_line("out_time_us=1234567", &mut p);
1379 assert_eq!(p.time, Duration::from_micros(1234567));
1380 }
1381
1382 #[test]
1383 fn test_parse_progress_returns_true_on_progress() {
1384 let mut p = Progress::default();
1385 assert!(parse_progress_line("progress=continue", &mut p));
1386 }
1387
1388 #[test]
1389 fn test_parse_progress_full_block() {
1390 let mut p = Progress::default();
1391 parse_progress_line("frame=1500", &mut p);
1392 parse_progress_line("fps=25.0", &mut p);
1393 parse_progress_line("bitrate=2000.0kbits/s", &mut p);
1394 parse_progress_line("speed=2.0x", &mut p);
1395 parse_progress_line("out_time_us=60000000", &mut p);
1396 assert!(parse_progress_line("progress=continue", &mut p));
1397 assert_eq!(p.frame, 1500);
1398 assert_eq!(p.time, Duration::from_secs(60));
1399 }
1400
1401 #[test]
1402 fn test_parse_progress_line_missing_equals() {
1403 let mut p = Progress::default();
1404 assert!(!parse_progress_line("noequals", &mut p));
1405 }
1406
1407 #[test]
1408 fn test_parse_progress_line_unknown_key() {
1409 let mut p = Progress::default();
1410 assert!(!parse_progress_line("unknown=42", &mut p));
1411 }
1412
1413 #[test]
1414 fn test_parse_progress_line_bogus_numbers() {
1415 let mut p = Progress::default();
1416 parse_progress_line("frame=abc", &mut p);
1417 assert_eq!(p.frame, 0);
1418 }
1419
1420 #[test]
1422 fn test_make_passlog_prefix_uses_output_dir() {
1423 let prefix = make_passlog_prefix("/path/to/video.mp4");
1424 assert!(prefix.starts_with(Path::new("/path/to")));
1425 assert!(prefix.to_string_lossy().contains("video"));
1426 }
1427
1428 #[test]
1429 fn test_make_passlog_prefix_no_parent_falls_back_to_cwd() {
1430 let prefix = make_passlog_prefix("video.mp4");
1431 assert!(prefix.starts_with(Path::new(".")));
1432 }
1433
1434 #[test]
1436 fn test_make_concat_list_path_is_txt() {
1437 let path = make_concat_list_path("output.mp4");
1438 assert!(path.to_string_lossy().ends_with(".txt"));
1439 }
1440
1441 #[test]
1443 fn test_escape_concat_path_escapes_single_quotes() {
1444 assert_eq!(escape_concat_path("video's.mp4"), "video\\'s.mp4");
1445 }
1446
1447 #[test]
1448 fn test_escape_concat_path_escapes_backslashes() {
1449 assert_eq!(escape_concat_path("dir\\video.mp4"), "dir\\\\video.mp4");
1450 }
1451
1452 #[test]
1453 fn test_escape_concat_path_no_change_for_simple_paths() {
1454 assert_eq!(escape_concat_path("/tmp/video.mp4"), "/tmp/video.mp4");
1455 }
1456
1457 #[tokio::test]
1459 async fn test_extract_rejects_negative_start() {
1460 let err = extract("in.mp4", "out.mp4", -1.0, 5.0).await.unwrap_err();
1461 assert!(err.to_string().contains("start must be non-negative"));
1462 }
1463
1464 #[tokio::test]
1465 async fn test_extract_rejects_zero_duration() {
1466 let err = extract("in.mp4", "out.mp4", 0.0, 0.0).await.unwrap_err();
1467 assert!(err.to_string().contains("duration must be positive"));
1468 }
1469
1470 #[tokio::test]
1471 async fn test_extract_rejects_negative_duration() {
1472 let err = extract("in.mp4", "out.mp4", 0.0, -5.0).await.unwrap_err();
1473 assert!(err.to_string().contains("duration must be positive"));
1474 }
1475
1476 #[tokio::test]
1477 async fn test_extract_rejects_nan_duration() {
1478 let err = extract("in.mp4", "out.mp4", 0.0, f64::NAN).await.unwrap_err();
1479 assert!(err.to_string().contains("duration must be positive"));
1480 }
1481
1482 fn hw_crf(codec: Codec) -> EncodeJob {
1484 EncodeJob {
1485 codec,
1486 preset: String::new(),
1487 resolution: None,
1488 extra_args: vec![],
1489 ..sample_job(RateControlMode::Crf)
1490 }
1491 }
1492
1493 fn hw_qp(codec: Codec) -> EncodeJob {
1494 EncodeJob {
1495 codec,
1496 preset: String::new(),
1497 resolution: None,
1498 extra_args: vec![],
1499 ..sample_job(RateControlMode::Qp)
1500 }
1501 }
1502
1503 #[test]
1505 fn test_nvenc_h264_crf_uses_constqp() {
1506 let args = build_encode_args(&hw_crf(Codec::NvencH264), EncodePass::Single).unwrap();
1507 assert!(has_pair(&args, "-rc", "constqp"));
1508 assert!(has_arg(&args, "-cq"));
1509 }
1510
1511 #[test]
1512 fn test_nvenc_h265_crf_uses_constqp() {
1513 let args = build_encode_args(&hw_crf(Codec::NvencH265), EncodePass::Single).unwrap();
1514 assert!(has_pair(&args, "-rc", "constqp"));
1515 assert!(has_arg(&args, "-cq"));
1516 }
1517
1518 #[test]
1519 fn test_qsv_h264_crf_uses_global_quality() {
1520 let args = build_encode_args(&hw_crf(Codec::QsvH264), EncodePass::Single).unwrap();
1521 assert!(has_arg(&args, "-global_quality"));
1522 }
1523
1524 #[test]
1525 fn test_qsv_h265_crf_uses_global_quality() {
1526 let args = build_encode_args(&hw_crf(Codec::QsvH265), EncodePass::Single).unwrap();
1527 assert!(has_arg(&args, "-global_quality"));
1528 }
1529
1530 #[test]
1531 fn test_vt_h264_crf_uses_quality() {
1532 let args = build_encode_args(&hw_crf(Codec::VideoToolboxH264), EncodePass::Single).unwrap();
1533 assert!(has_arg(&args, "-quality"));
1534 }
1535
1536 #[test]
1537 fn test_vt_h265_crf_uses_quality() {
1538 let args = build_encode_args(&hw_crf(Codec::VideoToolboxH265), EncodePass::Single).unwrap();
1539 assert!(has_arg(&args, "-quality"));
1540 }
1541
1542 #[test]
1543 fn test_vaapi_h264_crf_uses_global_quality() {
1544 let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1545 assert!(has_arg(&args, "-global_quality"));
1546 }
1547
1548 #[test]
1549 fn test_vaapi_h265_crf_uses_global_quality() {
1550 let args = build_encode_args(&hw_crf(Codec::VaapiH265), EncodePass::Single).unwrap();
1551 assert!(has_arg(&args, "-global_quality"));
1552 }
1553
1554 #[test]
1555 fn test_amf_h264_crf_uses_qp_and_usage() {
1556 let args = build_encode_args(&hw_crf(Codec::AmfH264), EncodePass::Single).unwrap();
1557 assert!(has_pair(&args, "-qp_i", "23"));
1558 assert!(has_pair(&args, "-qp_p", "25"));
1559 assert!(has_pair(&args, "-usage", "transcoding"));
1560 }
1561
1562 #[test]
1563 fn test_amf_h265_crf_uses_qp_and_usage() {
1564 let args = build_encode_args(&hw_crf(Codec::AmfH265), EncodePass::Single).unwrap();
1565 assert!(has_pair(&args, "-qp_i", "23"));
1566 assert!(has_pair(&args, "-qp_p", "25"));
1567 assert!(has_pair(&args, "-usage", "transcoding"));
1568 }
1569
1570 #[test]
1572 fn test_nvenc_h264_qp() {
1573 let args = build_encode_args(&hw_qp(Codec::NvencH264), EncodePass::Single).unwrap();
1574 assert!(has_pair(&args, "-qp", "23"));
1575 }
1576
1577 #[test]
1578 fn test_qsv_h264_qp() {
1579 let args = build_encode_args(&hw_qp(Codec::QsvH264), EncodePass::Single).unwrap();
1580 assert!(has_pair(&args, "-qp", "23"));
1581 }
1582
1583 #[test]
1584 fn test_vaapi_h264_qp() {
1585 let args = build_encode_args(&hw_qp(Codec::VaapiH264), EncodePass::Single).unwrap();
1586 assert!(has_pair(&args, "-qp", "23"));
1587 }
1588
1589 #[test]
1590 fn test_amf_h264_qp() {
1591 let args = build_encode_args(&hw_qp(Codec::AmfH264), EncodePass::Single).unwrap();
1592 assert!(has_pair(&args, "-qp", "23"));
1593 }
1594
1595 #[test]
1596 fn test_vt_qp_rejected() {
1597 let result = build_encode_args(&hw_qp(Codec::VideoToolboxH264), EncodePass::Single);
1598 assert!(result.is_err());
1599 }
1600
1601 #[test]
1602 fn test_vt_h265_qp_rejected() {
1603 let result = build_encode_args(&hw_qp(Codec::VideoToolboxH265), EncodePass::Single);
1604 assert!(result.is_err());
1605 }
1606
1607 #[test]
1609 fn test_nvenc_capped_crf_sets_vbv() {
1610 let job = EncodeJob {
1611 codec: Codec::NvencH264,
1612 max_bitrate: 5000.0,
1613 bufsize: 10000.0,
1614 rate_control: RateControlMode::CappedCrf,
1615 ..sample_job(RateControlMode::Crf)
1616 };
1617 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1618 assert!(has_pair(&args, "-rc", "vbr"));
1620 assert!(has_pair(&args, "-maxrate", "5000k"));
1621 assert!(has_pair(&args, "-bufsize", "10000k"));
1622 }
1623
1624 #[test]
1625 fn test_hw_capped_crf_max_bitrate_zero_errors() {
1626 let job = EncodeJob {
1627 codec: Codec::NvencH264,
1628 max_bitrate: 0.0,
1629 rate_control: RateControlMode::CappedCrf,
1630 ..sample_job(RateControlMode::Crf)
1631 };
1632 assert!(build_encode_args(&job, EncodePass::Single).is_err());
1633 }
1634
1635 #[test]
1637 fn test_nvenc_vbr_uses_vbr_hq() {
1638 let job = EncodeJob {
1639 codec: Codec::NvencH264,
1640 target_bitrate: 5000.0,
1641 rate_control: RateControlMode::Vbr,
1642 ..sample_job(RateControlMode::Vbr)
1643 };
1644 let passlog = Path::new("/tmp/plog");
1645 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1646 assert!(has_pair(&args, "-rc", "vbr_hq"));
1647 }
1648
1649 #[test]
1650 fn test_qsv_vbr_no_special_rc() {
1651 let job = EncodeJob {
1652 codec: Codec::QsvH264,
1653 target_bitrate: 5000.0,
1654 rate_control: RateControlMode::Vbr,
1655 ..sample_job(RateControlMode::Vbr)
1656 };
1657 let passlog = Path::new("/tmp/plog");
1658 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1659 assert!(!has_arg(&args, "-rc"));
1660 }
1661
1662 #[test]
1663 fn test_hw_vbr_target_bitrate_zero_errors() {
1664 let job = EncodeJob {
1665 codec: Codec::NvencH264,
1666 target_bitrate: 0.0,
1667 rate_control: RateControlMode::Vbr,
1668 ..sample_job(RateControlMode::Vbr)
1669 };
1670 let passlog = Path::new("/tmp/plog");
1671 assert!(build_encode_args(&job, EncodePass::Second(passlog)).is_err());
1672 }
1673
1674 #[test]
1676 fn test_nvenc_preset_maps_to_p_numbers() {
1677 let job = EncodeJob {
1678 codec: Codec::NvencH264,
1679 preset: "veryfast".into(),
1680 ..sample_job(RateControlMode::Crf)
1681 };
1682 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1683 assert!(has_pair(&args, "-preset", "p2"));
1684 }
1685
1686 #[test]
1687 fn test_vaapi_preset_uses_compression_level() {
1688 let job = EncodeJob {
1689 codec: Codec::VaapiH264,
1690 preset: "medium".into(),
1691 ..sample_job(RateControlMode::Crf)
1692 };
1693 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1694 assert!(has_pair(&args, "-compression_level", "3"));
1695 }
1696
1697 #[test]
1698 fn test_amf_preset_uses_quality() {
1699 let job = EncodeJob {
1700 codec: Codec::AmfH264,
1701 preset: "slow".into(),
1702 ..sample_job(RateControlMode::Crf)
1703 };
1704 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1705 assert!(has_pair(&args, "-quality", "quality"));
1706 }
1707
1708 #[test]
1709 fn test_amf_preset_speed() {
1710 let job = EncodeJob {
1711 codec: Codec::AmfH264,
1712 preset: "ultrafast".into(),
1713 ..sample_job(RateControlMode::Crf)
1714 };
1715 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1716 assert!(has_pair(&args, "-quality", "speed"));
1717 }
1718
1719 #[test]
1721 fn test_av1_hw_codecs_have_correct_codec_string() {
1722 for codec in &[Codec::NvencAv1, Codec::QsvAv1, Codec::VaapiAv1, Codec::AmfAv1] {
1723 let job = EncodeJob {
1724 codec: *codec,
1725 preset: String::new(),
1726 resolution: None,
1727 extra_args: vec![],
1728 ..sample_job(RateControlMode::Crf)
1729 };
1730 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1731 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1732 }
1733 }
1734
1735 #[test]
1736 fn test_av1_nvenc_crf_uses_constqp() {
1737 let args = build_encode_args(&hw_crf(Codec::NvencAv1), EncodePass::Single).unwrap();
1738 assert!(has_pair(&args, "-rc", "constqp"));
1739 assert!(has_arg(&args, "-cq"));
1740 }
1741
1742 #[test]
1743 fn test_av1_vaapi_crf_uses_global_quality() {
1744 let args = build_encode_args(&hw_crf(Codec::VaapiAv1), EncodePass::Single).unwrap();
1745 assert!(has_arg(&args, "-global_quality"));
1746 }
1747
1748 #[test]
1750 fn test_vaapi_sets_device_before_input() {
1751 let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1752 let dev_idx =
1753 args.iter().position(|a| a == "-vaapi_device").expect("missing -vaapi_device");
1754 let i_idx = args.iter().position(|a| a == "-i").expect("missing -i");
1755 assert!(dev_idx < i_idx, "-vaapi_device must precede -i: {args:?}");
1756 }
1757
1758 #[test]
1759 fn test_vaapi_filter_chain_has_hwupload() {
1760 let job = EncodeJob { codec: Codec::VaapiH264, ..sample_job(RateControlMode::Crf) };
1762 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1763 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1764 let vf = &args[vf_idx + 1];
1765 assert!(vf.contains("scale=1280:720:flags=lanczos"), "missing scale: {vf}");
1766 assert!(vf.contains("format=nv12,hwupload"), "missing hwupload: {vf}");
1767 assert_eq!(args.iter().filter(|a| *a == "-vf").count(), 1, "exactly one -vf: {args:?}");
1768 }
1769
1770 #[test]
1771 fn test_vaapi_hwupload_present_without_resolution() {
1772 let job = EncodeJob {
1773 codec: Codec::VaapiH264,
1774 resolution: None,
1775 ..sample_job(RateControlMode::Crf)
1776 };
1777 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1778 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1779 assert_eq!(args[vf_idx + 1], "format=nv12,hwupload");
1780 }
1781
1782 #[test]
1783 fn test_vaapi_hdr_uses_p010_surface_format() {
1784 let job = EncodeJob {
1785 codec: Codec::VaapiH265,
1786 resolution: None,
1787 source_format: Some(hdr10_svtav1_format()),
1788 ..sample_job(RateControlMode::Crf)
1789 };
1790 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1791 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1792 let vf = &args[vf_idx + 1];
1793 assert!(vf.contains("format=p010,hwupload"), "expected p010 format for HDR VAAPI: {vf}");
1794 }
1795
1796 #[test]
1797 fn test_vaapi_10bit_sdr_uses_p010_surface_format() {
1798 let job = EncodeJob {
1799 codec: Codec::VaapiH265,
1800 resolution: None,
1801 source_format: Some(SourceFormat {
1802 pix_fmt: "yuv420p10le".into(),
1803 bit_depth: 10,
1804 color_primaries: "bt709".into(),
1805 color_transfer: "bt709".into(),
1806 color_space: "bt709".into(),
1807 is_hdr: false,
1808 hdr10: None,
1809 }),
1810 ..sample_job(RateControlMode::Crf)
1811 };
1812 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1813 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1814 let vf = &args[vf_idx + 1];
1815 assert!(vf.contains("format=p010,hwupload"), "expected p010 for 10-bit VAAPI: {vf}");
1816 }
1817
1818 #[test]
1819 fn test_vaapi_hdr_with_resolution_uses_p010() {
1820 let job = EncodeJob {
1821 codec: Codec::VaapiH265,
1822 source_format: Some(hdr10_svtav1_format()),
1823 ..sample_job(RateControlMode::Crf)
1824 };
1825 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1826 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1827 let vf = &args[vf_idx + 1];
1828 assert!(vf.contains("format=p010,hwupload"), "expected p010 in HDR VAAPI: {vf}");
1829 assert!(vf.contains("scale="), "expected scale in HDR VAAPI: {vf}");
1830 }
1831
1832 #[test]
1833 fn test_vaapi_sdr_uses_nv12_surface_format() {
1834 let job = EncodeJob {
1835 codec: Codec::VaapiH264,
1836 resolution: None,
1837 ..sample_job(RateControlMode::Crf)
1838 };
1839 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1840 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1841 assert_eq!(args[vf_idx + 1], "format=nv12,hwupload");
1842 }
1843
1844 #[test]
1845 fn test_non_vaapi_has_no_hwupload_or_device() {
1846 let job = EncodeJob { codec: Codec::NvencH264, ..sample_job(RateControlMode::Crf) };
1847 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1848 assert!(!has_arg(&args, "-vaapi_device"));
1849 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1850 assert_eq!(args[vf_idx + 1], "scale=1280:720:flags=lanczos");
1851 }
1852
1853 #[test]
1855 fn test_hwaccel_injected_before_input() {
1856 let job = EncodeJob {
1857 codec: Codec::X264,
1858 hwaccel: Some("cuda".into()),
1859 ..sample_job(RateControlMode::Crf)
1860 };
1861 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1862 let acc_idx = args.iter().position(|a| a == "-hwaccel").expect("missing -hwaccel");
1863 let i_idx = args.iter().position(|a| a == "-i").expect("missing -i");
1864 assert_eq!(args[acc_idx + 1], "cuda");
1865 assert!(acc_idx < i_idx, "-hwaccel must precede -i: {args:?}");
1866 }
1867
1868 #[test]
1869 fn test_no_hwaccel_when_unset_or_empty() {
1870 for hw in [None, Some(String::new())] {
1871 let job =
1872 EncodeJob { codec: Codec::X264, hwaccel: hw, ..sample_job(RateControlMode::Crf) };
1873 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1874 assert!(!has_arg(&args, "-hwaccel"), "unexpected -hwaccel: {args:?}");
1875 }
1876 }
1877
1878 #[test]
1879 fn test_amf_preset_balanced() {
1880 let job = EncodeJob {
1881 codec: Codec::AmfH264,
1882 preset: "fast".into(),
1883 ..sample_job(RateControlMode::Crf)
1884 };
1885 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1886 assert!(has_pair(&args, "-quality", "balanced"));
1887 }
1888
1889 #[test]
1890 fn test_vt_preset_realtime_for_ultrafast() {
1891 let job = EncodeJob {
1892 codec: Codec::VideoToolboxH264,
1893 preset: "ultrafast".into(),
1894 ..sample_job(RateControlMode::Crf)
1895 };
1896 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1897 assert!(has_pair(&args, "-realtime", "1"));
1898 }
1899
1900 #[test]
1901 fn test_vt_preset_realtime_for_veryfast() {
1902 let job = EncodeJob {
1903 codec: Codec::VideoToolboxH264,
1904 preset: "veryfast".into(),
1905 ..sample_job(RateControlMode::Crf)
1906 };
1907 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1908 assert!(has_pair(&args, "-realtime", "1"));
1909 }
1910
1911 #[test]
1912 fn test_vt_preset_no_realtime_for_slow() {
1913 let job = EncodeJob {
1914 codec: Codec::VideoToolboxH264,
1915 preset: "slow".into(),
1916 ..sample_job(RateControlMode::Crf)
1917 };
1918 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1919 assert!(!has_arg(&args, "-realtime"));
1920 }
1921
1922 #[test]
1923 fn test_qsv_preset_passthrough() {
1924 let job = EncodeJob {
1925 codec: Codec::QsvH264,
1926 preset: "medium".into(),
1927 ..sample_job(RateControlMode::Crf)
1928 };
1929 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1930 assert!(has_pair(&args, "-preset", "medium"));
1931 }
1932
1933 #[test]
1935 fn test_crf_to_nvenc_cq_bounds() {
1936 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); }
1941
1942 #[test]
1943 fn test_crf_to_nvenc_cq_typical_values() {
1944 assert_eq!(crf_to_nvenc_cq(23), 18); assert_eq!(crf_to_nvenc_cq(30), 24); }
1947
1948 #[test]
1949 fn test_crf_to_qsv_quality_bounds() {
1950 let q0 = crf_to_qsv_quality(0);
1951 assert!((95..=100).contains(&q0)); let q51 = crf_to_qsv_quality(51);
1953 assert_eq!(q51, 1); let q100 = crf_to_qsv_quality(100);
1955 assert_eq!(q100, 1); }
1957
1958 #[test]
1959 fn test_crf_to_qsv_quality_mid() {
1960 let q = crf_to_qsv_quality(25);
1961 assert!((50..=52).contains(&q));
1963 }
1964
1965 #[test]
1966 fn test_crf_to_vt_quality_bounds() {
1967 assert!((crf_to_vt_quality(0) - 1.0).abs() < 1e-9);
1968 assert!((crf_to_vt_quality(51) - 0.0).abs() < 1e-9);
1969 assert!((crf_to_vt_quality(100) - 0.0).abs() < 1e-9);
1970 }
1971
1972 #[test]
1973 fn test_crf_to_vt_quality_mid() {
1974 let q = crf_to_vt_quality(25);
1975 assert!(q > 0.4 && q < 0.6);
1976 }
1977
1978 #[test]
1980 fn test_crf_zero() {
1981 let job = EncodeJob { crf: 0, ..sample_job(RateControlMode::Crf) };
1982 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1983 assert!(has_pair(&args, "-crf", "0"));
1984 }
1985
1986 #[test]
1987 fn test_crf_high_value() {
1988 let job = EncodeJob { crf: 51, ..sample_job(RateControlMode::Crf) };
1989 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1990 assert!(has_pair(&args, "-crf", "51"));
1991 }
1992
1993 #[test]
1994 fn test_crf_negative_allowed() {
1995 let job = EncodeJob { crf: -1, ..sample_job(RateControlMode::Crf) };
1996 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1997 assert!(has_pair(&args, "-crf", "-1"));
1998 }
1999
2000 #[test]
2001 fn test_input_arg_is_present() {
2002 let args =
2003 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
2004 assert!(has_pair(&args, "-i", "input.mp4"));
2005 }
2006
2007 #[test]
2008 fn test_no_audio_flag_is_present() {
2009 let args =
2010 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
2011 assert!(has_arg(&args, "-an"));
2012 }
2013
2014 #[test]
2016 fn test_all_sw_codecs_have_correct_codec_string() {
2017 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1, Codec::Vp9] {
2018 let job = EncodeJob {
2019 codec: *codec,
2020 preset: String::new(),
2021 resolution: None,
2022 extra_args: vec![],
2023 ..sample_job(RateControlMode::Crf)
2024 };
2025 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2026 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
2027 }
2028 }
2029
2030 #[test]
2031 fn test_all_hw_codecs_have_correct_codec_string() {
2032 for codec in &[
2033 Codec::NvencH264,
2034 Codec::NvencH265,
2035 Codec::QsvH264,
2036 Codec::QsvH265,
2037 Codec::VideoToolboxH264,
2038 Codec::VideoToolboxH265,
2039 Codec::VaapiH264,
2040 Codec::VaapiH265,
2041 Codec::AmfH264,
2042 Codec::AmfH265,
2043 ] {
2044 let job = EncodeJob {
2045 codec: *codec,
2046 preset: String::new(),
2047 resolution: None,
2048 extra_args: vec![],
2049 ..sample_job(RateControlMode::Crf)
2050 };
2051 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2052 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
2053 }
2054 }
2055
2056 #[cfg(test)]
2058 mod proptests {
2059 use super::*;
2060 use proptest::prelude::*;
2061
2062 fn arb_codec() -> impl Strategy<Value = Codec> {
2063 prop_oneof![
2064 Just(Codec::X264),
2065 Just(Codec::X265),
2066 Just(Codec::SvtAv1),
2067 Just(Codec::Vp9),
2068 Just(Codec::NvencH264),
2069 Just(Codec::NvencH265),
2070 Just(Codec::QsvH264),
2071 Just(Codec::QsvH265),
2072 Just(Codec::VideoToolboxH264),
2073 Just(Codec::VideoToolboxH265),
2074 Just(Codec::VaapiH264),
2075 Just(Codec::VaapiH265),
2076 Just(Codec::AmfH264),
2077 Just(Codec::AmfH265),
2078 Just(Codec::NvencAv1),
2079 Just(Codec::QsvAv1),
2080 Just(Codec::VaapiAv1),
2081 Just(Codec::AmfAv1),
2082 ]
2083 }
2084
2085 fn arb_rate_control() -> impl Strategy<Value = RateControlMode> {
2086 prop_oneof![
2087 Just(RateControlMode::Crf),
2088 Just(RateControlMode::Qp),
2089 Just(RateControlMode::CappedCrf),
2090 ]
2091 }
2092
2093 fn arb_encode_job() -> impl Strategy<Value = EncodeJob> {
2094 (
2095 arb_codec(),
2096 arb_rate_control(),
2097 any::<i32>(),
2098 any::<f64>(),
2099 any::<f64>(),
2100 any::<f64>(),
2101 any::<String>(),
2102 )
2103 .prop_map(|(codec, rc, crf, target_br, max_br, bufsize, preset)| {
2104 let crf = crf.abs().min(63);
2105 EncodeJob {
2106 input: "input.mp4".into(),
2107 output: "output.mp4".into(),
2108 resolution: Some(Resolution::new(1920, 1080)),
2109 codec,
2110 crf,
2111 rate_control: rc,
2112 target_bitrate: target_br.abs().min(100000.0),
2113 max_bitrate: max_br.abs().min(100000.0),
2114 bufsize: bufsize.abs().min(200000.0),
2115 preset,
2116 hwaccel: None,
2117 extra_args: vec![],
2118 source_format: None,
2119 }
2120 })
2121 }
2122
2123 proptest! {
2124 #[test]
2128 fn args_start_with_overwrite_and_input(job in arb_encode_job()) {
2129 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2130 assert!(args.len() >= 3, "too few args: {args:?}");
2131 assert_eq!(args[0], "-y", "first arg must be -y");
2132 let i_idx = args.iter().position(|a| a == "-i").expect("must contain -i");
2133 assert_eq!(args[i_idx + 1], "input.mp4", "input path must follow -i");
2134 }
2135 }
2136
2137 #[test]
2139 fn args_have_no_audio_flag(job in arb_encode_job()) {
2140 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2141 assert!(has_arg(&args, "-an"),
2142 "missing -an: {args:?}");
2143 }
2144 }
2145
2146 #[test]
2148 fn args_have_correct_codec(job in arb_encode_job()) {
2149 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2150 assert!(has_pair(&args, "-c:v", job.codec.as_str()),
2151 "missing or wrong -c:v: {args:?}, expected {}", job.codec.as_str());
2152 }
2153 }
2154
2155 #[test]
2157 fn output_is_the_last_argument(job in arb_encode_job()) {
2158 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2159 assert_eq!(args.last().unwrap(), "output.mp4",
2160 "output not last: {args:?}");
2161 }
2162 }
2163
2164 #[test]
2167 fn no_duplicate_flag_keys(job in arb_encode_job()) {
2168 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2169 let mut seen = std::collections::HashSet::new();
2170 for arg_chunk in args.chunks(2) {
2171 if arg_chunk[0].starts_with('-') {
2172 assert!(seen.insert(&arg_chunk[0]),
2173 "duplicate flag {} in {args:?}", arg_chunk[0]);
2174 }
2175 }
2176 }
2177 }
2178
2179 #[test]
2181 fn sw_crf_has_crf_flag(
2182 crf in 0i32..63i32,
2183 preset in ".*",
2184 ) {
2185 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1, Codec::Vp9] {
2186 let job = EncodeJob {
2187 codec: *codec, crf, rate_control: RateControlMode::Crf,
2188 preset: preset.clone(), resolution: None, extra_args: vec![],
2189 ..sample_job(RateControlMode::Crf)
2190 };
2191 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2192 assert!(has_pair(&args, "-crf", &crf.to_string()),
2193 "{codec:?}: missing -crf {crf} in {args:?}");
2194 }
2195 }
2196
2197 #[test]
2199 fn sw_crf_and_qp_never_both_present(
2200 crf in 0i32..63i32,
2201 mode in prop_oneof![Just(RateControlMode::Crf), Just(RateControlMode::Qp)],
2202 ) {
2203 for codec in &[Codec::X264, Codec::X265] {
2204 let job = EncodeJob {
2205 codec: *codec, crf, rate_control: mode, preset: String::new(),
2206 resolution: None, extra_args: vec![],
2207 ..sample_job(mode)
2208 };
2209 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2210 let has_crf = has_arg(&args, "-crf");
2211 let has_qp = has_arg(&args, "-qp");
2212 assert!(!(has_crf && has_qp),
2213 "{codec:?} mode={mode:?}: both -crf and -qp present: {args:?}");
2214 }
2215 }
2216 }
2217
2218 #[test]
2220 fn capped_crf_has_rate_control_args(job in arb_encode_job_sw_capped()) {
2221 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2222 let maxrate_idx = args.iter().position(|a| a == "-maxrate");
2224 if let Some(idx) = maxrate_idx {
2225 let val = &args[idx + 1];
2226 assert!(val.ends_with('k'),
2227 "-maxrate value should end with 'k': {val}");
2228 }
2229 let bufsize_idx = args.iter().position(|a| a == "-bufsize");
2230 if let Some(idx) = bufsize_idx {
2231 let val = &args[idx + 1];
2232 assert!(val.ends_with('k'),
2233 "-bufsize value should end with 'k': {val}");
2234 }
2235 }
2236 }
2237
2238 #[test]
2240 fn vbr_first_pass_has_null_output(
2241 job in arb_encode_job_sw_vbr(),
2242 ) {
2243 let passlog = Path::new("/tmp/plog");
2244 if let Ok(args) = build_encode_args(&job, EncodePass::First(passlog)) {
2245 assert!(!has_arg(&args, "-progress"),
2246 "first pass should not have -progress: {args:?}");
2247 assert!(has_pair(&args, "-f", "null"),
2248 "first pass must write to null: {args:?}");
2249 }
2250 }
2251
2252 #[test]
2254 fn svtav1_qp_disables_aq(
2255 crf in 1i32..63i32,
2256 preset in ".*",
2257 ) {
2258 let job = EncodeJob {
2259 codec: Codec::SvtAv1, crf, rate_control: RateControlMode::Qp,
2260 preset, resolution: None, extra_args: vec![],
2261 ..sample_job(RateControlMode::Qp)
2262 };
2263 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2264 assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"),
2265 "SVT-AV1 QP must disable adaptive quantization: {args:?}");
2266 }
2267
2268 #[test]
2270 fn nvenc_crf_uses_cq_not_crf(
2271 crf in 0i32..63i32,
2272 h264_h265 in prop_oneof![Just(Codec::NvencH264), Just(Codec::NvencH265)],
2273 ) {
2274 let job = EncodeJob {
2275 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
2276 preset: String::new(), resolution: None, extra_args: vec![],
2277 ..sample_job(RateControlMode::Crf)
2278 };
2279 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2280 assert!(has_pair(&args, "-rc", "constqp"),
2281 "NVENC CRF missing -rc constqp: {args:?}");
2282 assert!(has_arg(&args, "-cq"),
2283 "NVENC CRF missing -cq: {args:?}");
2284 assert!(!has_arg(&args, "-crf"),
2285 "NVENC must not use -crf: {args:?}");
2286 }
2287
2288 #[test]
2290 fn qsv_crf_uses_global_quality(
2291 crf in 0i32..63i32,
2292 h264_h265 in prop_oneof![Just(Codec::QsvH264), Just(Codec::QsvH265)],
2293 ) {
2294 let job = EncodeJob {
2295 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
2296 preset: String::new(), resolution: None, extra_args: vec![],
2297 ..sample_job(RateControlMode::Crf)
2298 };
2299 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2300 assert!(has_arg(&args, "-global_quality"),
2301 "QSV CRF missing -global_quality: {args:?}");
2302 assert!(!has_arg(&args, "-crf"),
2303 "QSV must not use -crf: {args:?}");
2304 }
2305
2306 #[test]
2308 fn amf_crf_uses_qp_pairs(
2309 crf in 0i32..63i32,
2310 h264_h265 in prop_oneof![Just(Codec::AmfH264), Just(Codec::AmfH265)],
2311 ) {
2312 let job = EncodeJob {
2313 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
2314 preset: String::new(), resolution: None, extra_args: vec![],
2315 ..sample_job(RateControlMode::Crf)
2316 };
2317 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2318 assert!(has_pair(&args, "-qp_i", &crf.to_string()),
2319 "AMF missing -qp_i: {args:?}");
2320 assert!(!has_arg(&args, "-crf"),
2321 "AMF must not use -crf: {args:?}");
2322 }
2323
2324 #[test]
2326 fn videotoolbox_qp_always_rejected(
2327 crf in 0i32..63i32,
2328 h264_h265 in prop_oneof![Just(Codec::VideoToolboxH264), Just(Codec::VideoToolboxH265)],
2329 ) {
2330 let job = EncodeJob {
2331 codec: h264_h265, crf, rate_control: RateControlMode::Qp,
2332 preset: String::new(), resolution: None, extra_args: vec![],
2333 ..sample_job(RateControlMode::Qp)
2334 };
2335 assert!(build_encode_args(&job, EncodePass::Single).is_err(),
2336 "VideoToolbox QP should be rejected");
2337 }
2338
2339 #[test]
2342 fn vbr_single_pass_errors_for_sw_codecs(
2343 target_br in 100.0f64..100000.0f64,
2344 ) {
2345 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1, Codec::Vp9] {
2346 let job = EncodeJob {
2347 codec: *codec, rate_control: RateControlMode::Vbr,
2348 target_bitrate: target_br,
2349 ..sample_job(RateControlMode::Vbr)
2350 };
2351 assert!(build_encode_args(&job, EncodePass::Single).is_err(),
2352 "{codec:?} VBR single-pass should error");
2353 }
2354 }
2355
2356 #[test]
2358 fn hw_vbr_single_pass_is_valid(
2359 target_br in 100.0f64..100000.0f64,
2360 codec in prop_oneof![
2361 Just(Codec::NvencH264), Just(Codec::QsvH264),
2362 Just(Codec::VideoToolboxH264), Just(Codec::VaapiH264), Just(Codec::AmfH264),
2363 ],
2364 ) {
2365 let job = EncodeJob {
2366 codec, rate_control: RateControlMode::Vbr,
2367 target_bitrate: target_br,
2368 resolution: None, preset: String::new(), extra_args: vec![],
2369 ..sample_job(RateControlMode::Vbr)
2370 };
2371 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2372 assert!(has_pair(&args, "-b:v", &format!("{target_br:.0}k")),
2373 "HW VBR single-pass missing -b:v: {args:?}");
2374 }
2375
2376 #[test]
2378 fn single_pass_output_is_file(job in arb_encode_job()) {
2379 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2380 let last = args.last().unwrap();
2381 assert!(!last.starts_with('-'),
2382 "last arg should not be a flag: {last}");
2383 assert!(!last.is_empty(),
2384 "last arg should not be empty");
2385 }
2386 }
2387 }
2388
2389 fn arb_encode_job_sw_capped() -> impl Strategy<Value = EncodeJob> {
2390 (any::<i32>(), any::<f64>(), any::<f64>(), any::<String>()).prop_map(
2391 |(crf, max_br, bufsize, preset)| {
2392 let crf = crf.abs().min(63);
2393 let max_br = max_br.abs().clamp(100.0, 100000.0);
2394 EncodeJob {
2395 codec: Codec::X264,
2396 crf,
2397 rate_control: RateControlMode::CappedCrf,
2398 max_bitrate: max_br,
2399 bufsize: bufsize.abs().min(200000.0),
2400 preset,
2401 resolution: None,
2402 extra_args: vec![],
2403 ..sample_job(RateControlMode::CappedCrf)
2404 }
2405 },
2406 )
2407 }
2408
2409 fn arb_encode_job_sw_vbr() -> impl Strategy<Value = EncodeJob> {
2410 (any::<i32>(), any::<f64>(), any::<String>()).prop_map(|(crf, target_br, preset)| {
2411 let crf = crf.abs().min(63);
2412 let target_br = target_br.abs().clamp(100.0, 100000.0);
2413 EncodeJob {
2414 codec: Codec::X264,
2415 crf,
2416 rate_control: RateControlMode::Vbr,
2417 target_bitrate: target_br,
2418 preset,
2419 resolution: None,
2420 extra_args: vec![],
2421 ..sample_job(RateControlMode::Vbr)
2422 }
2423 })
2424 }
2425 }
2426}