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 if job.codec == Codec::Vp9 {
283 add_vp9_preset(&mut args, &job.preset);
284 } else {
285 args.extend(["-preset".into(), job.preset.clone()]);
286 }
287 }
288
289 if let Some(format) = &job.source_format {
290 args.extend(encode_color_args(job.codec, format));
291 }
292
293 if let Some(vf) = build_filter_chain(job) {
294 args.extend(["-vf".into(), vf]);
295 }
296
297 args.extend(job.extra_args.iter().cloned());
298
299 if job.codec == Codec::SvtAv1 {
302 coalesce_repeated_flag(&mut args, "-svtav1-params", ":");
303 }
304
305 match pass {
306 EncodePass::First(_) => {
307 args.extend(["-f".into(), "null".into()]);
308 args.push(null_output_path().into());
309 }
310 EncodePass::Single | EncodePass::Second(_) => args.push(job.output.clone()),
311 }
312
313 Ok(args)
314}
315
316fn coalesce_repeated_flag(args: &mut Vec<String>, flag: &str, sep: &str) {
323 let positions: Vec<usize> =
324 (0..args.len().saturating_sub(1)).filter(|&i| args[i] == flag).collect();
325 if positions.len() < 2 {
326 return;
327 }
328 let merged = positions.iter().map(|&i| args[i + 1].clone()).collect::<Vec<_>>().join(sep);
329 let drop: std::collections::HashSet<usize> =
332 positions.iter().skip(1).flat_map(|&i| [i, i + 1]).collect();
333 let first_value = positions[0] + 1;
334 let mut out = Vec::with_capacity(args.len());
335 for (i, a) in args.drain(..).enumerate() {
336 if drop.contains(&i) {
337 continue;
338 }
339 out.push(if i == first_value { merged.clone() } else { a });
340 }
341 *args = out;
342}
343
344fn vaapi_device() -> String {
347 std::env::var("VISER_VAAPI_DEVICE").unwrap_or_else(|_| "/dev/dri/renderD128".to_string())
348}
349
350fn build_filter_chain(job: &EncodeJob) -> Option<String> {
357 let scale = job
358 .resolution
359 .filter(|res| res.width > 0 && res.height > 0)
360 .map(|res| format!("scale={}:{}:flags=lanczos", res.width, res.height));
361
362 if job.codec.backend() == EncoderBackend::Vaapi {
363 Some(match scale {
366 Some(s) => format!("{s},format=nv12,hwupload"),
367 None => "format=nv12,hwupload".to_string(),
368 })
369 } else {
370 scale
371 }
372}
373
374fn build_sw_args(
375 args: &mut Vec<String>,
376 job: &EncodeJob,
377 pass: &EncodePass<'_>,
378) -> anyhow::Result<()> {
379 match job.rate_control {
380 RateControlMode::Qp => {
381 if job.codec == Codec::SvtAv1 {
382 args.extend(["-qp".into(), job.crf.to_string()]);
383 args.extend(["-svtav1-params".into(), "enable-adaptive-quantization=0".into()]);
384 } else {
385 args.extend(["-qp".into(), job.crf.to_string()]);
386 }
387 }
388 RateControlMode::CappedCrf => {
389 if job.max_bitrate <= 0.0 {
390 anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
391 }
392 args.extend(["-crf".into(), job.crf.to_string()]);
393 if job.codec == Codec::Vp9 {
394 args.extend(["-b:v".into(), format!("{:.0}k", job.max_bitrate)]);
396 args.extend(["-deadline".into(), "good".into()]);
397 } else {
398 let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
399 args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
400 args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
401 }
402 }
403 RateControlMode::Vbr => {
404 if job.target_bitrate <= 0.0 {
405 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
406 }
407 args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
408 args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
409 args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
410
411 let passlog = match pass {
412 EncodePass::First(path) => {
413 args.extend(["-pass".into(), "1".into()]);
414 path
415 }
416 EncodePass::Second(path) => {
417 args.extend(["-pass".into(), "2".into()]);
418 path
419 }
420 EncodePass::Single => {
421 anyhow::bail!("VBR mode requires a two-pass encode flow");
422 }
423 };
424 args.extend(["-passlogfile".into(), passlog.to_string_lossy().into_owned()]);
425 }
426 RateControlMode::Crf => {
427 args.extend(["-crf".into(), job.crf.to_string()]);
428 }
429 }
430 Ok(())
431}
432
433fn build_hw_args(
434 args: &mut Vec<String>,
435 job: &EncodeJob,
436 _pass: &EncodePass<'_>,
437) -> anyhow::Result<()> {
438 let backend = job.codec.backend();
439 match job.rate_control {
440 RateControlMode::Crf | RateControlMode::CappedCrf => {
441 match backend {
442 EncoderBackend::Nvenc => {
443 let cq = crf_to_nvenc_cq(job.crf);
444 args.extend(["-cq".into(), cq.to_string()]);
445 let rc = if matches!(job.rate_control, RateControlMode::CappedCrf) {
448 "vbr"
449 } else {
450 "constqp"
451 };
452 args.extend(["-rc".into(), rc.into()]);
453 }
454 EncoderBackend::Qsv => {
455 let gq = crf_to_qsv_quality(job.crf);
456 args.extend(["-global_quality".into(), gq.to_string()]);
457 }
458 EncoderBackend::VideoToolbox => {
459 let qual = crf_to_vt_quality(job.crf);
460 args.extend(["-quality".into(), qual.to_string()]);
461 }
462 EncoderBackend::Vaapi => {
463 let gq = crf_to_qsv_quality(job.crf);
464 args.extend(["-global_quality".into(), gq.to_string()]);
465 }
466 EncoderBackend::Amf => {
467 args.extend(["-qp_i".into(), job.crf.to_string()]);
468 args.extend(["-qp_p".into(), (job.crf + 2).to_string()]);
469 args.extend(["-usage".into(), "transcoding".into()]);
470 }
471 EncoderBackend::Software => unreachable!(),
472 }
473 if let RateControlMode::CappedCrf = job.rate_control {
475 if job.max_bitrate <= 0.0 {
476 anyhow::bail!("max bitrate must be greater than zero for capped CRF mode");
477 }
478 let bufsize = if job.bufsize > 0.0 { job.bufsize } else { job.max_bitrate * 2.0 };
479 args.extend(["-maxrate".into(), format!("{:.0}k", job.max_bitrate)]);
480 args.extend(["-bufsize".into(), format!("{bufsize:.0}k")]);
481 }
482 }
483 RateControlMode::Qp => match backend {
484 EncoderBackend::VideoToolbox => {
485 anyhow::bail!("VideoToolbox does not support QP rate control mode");
486 }
487 _ => {
488 args.extend(["-qp".into(), job.crf.to_string()]);
489 }
490 },
491 RateControlMode::Vbr => {
492 if job.target_bitrate <= 0.0 {
493 anyhow::bail!("target bitrate must be greater than zero for VBR mode");
494 }
495 args.extend(["-b:v".into(), format!("{:.0}k", job.target_bitrate)]);
496 args.extend(["-maxrate".into(), format!("{:.0}k", job.target_bitrate * 2.0)]);
497 args.extend(["-bufsize".into(), format!("{:.0}k", job.target_bitrate * 4.0)]);
498
499 if backend == EncoderBackend::Nvenc {
500 args.extend(["-rc".into(), "vbr_hq".into()]);
501 }
502 }
503 }
504 Ok(())
505}
506
507fn crf_to_nvenc_cq(crf: i32) -> i32 {
508 let cq = (crf * 51) / 63;
509 cq.clamp(1, 51)
510}
511
512fn crf_to_qsv_quality(crf: i32) -> i32 {
513 let gq = 100 - ((crf * 100) / 51);
514 gq.clamp(1, 100)
515}
516
517fn crf_to_vt_quality(crf: i32) -> f64 {
518 let q = 1.0 - (crf as f64 / 51.0);
519 q.clamp(0.0, 1.0)
520}
521
522fn add_vp9_preset(args: &mut Vec<String>, preset: &str) {
523 args.extend(["-cpu-used".into(), map_vp9_cpu_used(preset).into()]);
524 args.extend(["-deadline".into(), "good".into()]);
525 args.extend(["-row-mt".into(), "1".into()]);
526}
527
528fn map_vp9_cpu_used(preset: &str) -> &str {
529 match preset {
530 "0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" => preset,
531 "ultrafast" | "superfast" => "8",
532 "veryfast" => "6",
533 "faster" => "5",
534 "fast" => "4",
535 "medium" => "2",
536 "slow" => "1",
537 "slower" | "veryslow" => "0",
538 other => other,
539 }
540}
541
542fn add_hw_preset(args: &mut Vec<String>, codec: Codec, preset: &str) {
543 match codec.backend() {
544 EncoderBackend::Nvenc => {
545 let p = map_nvenc_preset(preset);
546 args.extend(["-preset".into(), p.into()]);
547 }
548 EncoderBackend::Qsv => {
549 args.extend(["-preset".into(), preset.to_string()]);
550 }
551 EncoderBackend::Vaapi => {
552 args.extend(["-compression_level".into(), map_vaapi_preset(preset).into()]);
553 }
554 EncoderBackend::Amf => {
555 args.extend(["-quality".into(), map_amf_quality(preset).into()]);
556 }
557 EncoderBackend::VideoToolbox => {
558 if preset == "ultrafast" || preset == "superfast" || preset == "veryfast" {
559 args.extend(["-realtime".into(), "1".into()]);
560 }
561 }
562 EncoderBackend::Software => unreachable!(),
563 }
564}
565
566fn map_nvenc_preset(preset: &str) -> &str {
567 match preset {
568 "ultrafast" | "superfast" => "p1",
569 "veryfast" => "p2",
570 "faster" => "p3",
571 "fast" => "p4",
572 "medium" => "p5",
573 "slow" => "p6",
574 "slower" | "veryslow" => "p7",
575 other => other,
576 }
577}
578
579fn map_vaapi_preset(preset: &str) -> &str {
580 match preset {
581 "ultrafast" | "superfast" => "1",
582 "veryfast" | "faster" => "2",
583 "fast" | "medium" => "3",
584 "slow" => "4",
585 "slower" | "veryslow" => "5",
586 other => other,
587 }
588}
589
590fn map_amf_quality(preset: &str) -> &str {
591 match preset {
592 "ultrafast" | "superfast" => "speed",
593 "veryfast" | "faster" | "fast" => "balanced",
594 "medium" | "slow" | "slower" | "veryslow" => "quality",
595 other => other,
596 }
597}
598
599fn escape_concat_path(path: &str) -> String {
603 path.replace('\\', "\\\\").replace('\'', "\\'")
604}
605
606fn make_passlog_prefix(output: &str) -> PathBuf {
607 let output_path = Path::new(output);
608 let parent =
609 output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
610 let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
611 let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
612 parent.join(format!(".{stem}.viser-passlog-{unique}-{}", std::process::id()))
613}
614
615fn make_concat_list_path(output: &str) -> PathBuf {
616 let output_path = Path::new(output);
617 let parent =
618 output_path.parent().filter(|p| !p.as_os_str().is_empty()).unwrap_or(Path::new("."));
619 let stem = output_path.file_stem().and_then(|s| s.to_str()).unwrap_or("viser");
620 let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0);
621 parent.join(format!(".{stem}.viser-concat-{unique}-{}.txt", std::process::id()))
622}
623
624fn null_output_path() -> &'static str {
625 if cfg!(windows) { "NUL" } else { "/dev/null" }
626}
627
628struct PasslogCleanup {
629 parent: PathBuf,
630 prefix: String,
631}
632
633impl PasslogCleanup {
634 fn new(path: PathBuf) -> Self {
635 let parent = path.parent().unwrap_or(Path::new(".")).to_path_buf();
636 let prefix = path.file_name().and_then(|name| name.to_str()).unwrap_or_default().to_owned();
637 Self { parent, prefix }
638 }
639
640 fn run(&self) {
641 let Ok(entries) = std::fs::read_dir(&self.parent) else {
642 return;
643 };
644
645 for entry in entries.flatten() {
646 let path = entry.path();
647 let Some(name) = path.file_name().and_then(|name| name.to_str()) else {
648 continue;
649 };
650 if !name.starts_with(&self.prefix) {
651 continue;
652 }
653 if let Err(err) = std::fs::remove_file(&path) {
654 tracing::debug!(?path, ?err, "failed to remove ffmpeg two-pass log file");
655 }
656 }
657 }
658}
659
660impl Drop for PasslogCleanup {
661 fn drop(&mut self) {
662 self.run();
663 }
664}
665
666fn parse_progress_line(line: &str, p: &mut Progress) -> bool {
668 let Some((key, value)) = line.split_once('=') else {
669 return false;
670 };
671
672 match key {
673 "frame" => {
674 p.frame = value.parse().unwrap_or(0);
675 }
676 "fps" => {
677 p.fps = value.parse().unwrap_or(0.0);
678 }
679 "bitrate" => {
680 let v = value.trim_end_matches("kbits/s");
681 p.bitrate = v.parse().unwrap_or(0.0);
682 }
683 "speed" => {
684 let v = value.trim_end_matches('x');
685 p.speed = v.parse().unwrap_or(0.0);
686 }
687 "out_time_us" => {
688 let us: u64 = value.parse().unwrap_or(0);
689 p.time = Duration::from_micros(us);
690 }
691 "progress" => return true,
692 _ => {}
693 }
694 false
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700 use crate::Codec;
701
702 fn sample_job(mode: RateControlMode) -> EncodeJob {
703 EncodeJob {
704 input: "input.mp4".into(),
705 output: "output.mp4".into(),
706 resolution: Some(crate::Resolution::new(1280, 720)),
707 codec: Codec::X264,
708 crf: 23,
709 rate_control: mode,
710 target_bitrate: 2500.0,
711 max_bitrate: 3000.0,
712 bufsize: 6000.0,
713 preset: "medium".into(),
714 hwaccel: None,
715 extra_args: vec![],
716 source_format: None,
717 }
718 }
719
720 fn job_with_codec(codec: Codec, mode: RateControlMode) -> EncodeJob {
721 EncodeJob { codec, rate_control: mode, ..sample_job(mode) }
722 }
723
724 fn has_pair(args: &[String], a: &str, b: &str) -> bool {
726 args.windows(2).any(|w| w[0] == a && w[1] == b)
727 }
728
729 fn has_arg(args: &[String], a: &str) -> bool {
730 args.iter().any(|s| s == a)
731 }
732
733 fn hdr10_svtav1_format() -> SourceFormat {
734 use crate::{Hdr10Metadata, MasteringDisplay};
735 SourceFormat {
736 pix_fmt: "yuv420p10le".into(),
737 bit_depth: 10,
738 color_primaries: "bt2020".into(),
739 color_transfer: "smpte2084".into(),
740 color_space: "bt2020nc".into(),
741 is_hdr: true,
742 hdr10: Some(Hdr10Metadata {
743 mastering_display: Some(MasteringDisplay {
744 green_x: 13250,
745 green_y: 34500,
746 blue_x: 7500,
747 blue_y: 3000,
748 red_x: 34000,
749 red_y: 16000,
750 white_x: 15635,
751 white_y: 16450,
752 max_luminance: 10_000_000,
753 min_luminance: 50,
754 }),
755 max_cll: Some(1000),
756 max_fall: Some(400),
757 }),
758 }
759 }
760
761 #[test]
762 fn test_coalesce_repeated_flag_merges_values() {
763 let mut args = vec![
764 "-i".into(),
765 "in.mp4".into(),
766 "-svtav1-params".into(),
767 "enable-adaptive-quantization=0".into(),
768 "-pix_fmt".into(),
769 "yuv420p10le".into(),
770 "-svtav1-params".into(),
771 "mastering-display=X:content-light=1000,400".into(),
772 "out.mp4".into(),
773 ];
774 coalesce_repeated_flag(&mut args, "-svtav1-params", ":");
775 assert_eq!(args.iter().filter(|a| *a == "-svtav1-params").count(), 1);
777 let merged =
778 args.windows(2).find(|w| w[0] == "-svtav1-params").map(|w| w[1].clone()).unwrap();
779 assert_eq!(
780 merged,
781 "enable-adaptive-quantization=0:mastering-display=X:content-light=1000,400"
782 );
783 assert!(has_pair(&args, "-pix_fmt", "yuv420p10le"));
785 assert_eq!(args.last().unwrap(), "out.mp4");
786 }
787
788 #[test]
789 fn test_coalesce_repeated_flag_noop_when_single() {
790 let mut args = vec!["-svtav1-params".into(), "a=1".into(), "out.mp4".into()];
791 let before = args.clone();
792 coalesce_repeated_flag(&mut args, "-svtav1-params", ":");
793 assert_eq!(args, before);
794 }
795
796 #[test]
797 fn test_build_encode_args_svtav1_qp_hdr_single_params() {
798 let mut job = job_with_codec(Codec::SvtAv1, RateControlMode::Qp);
799 job.resolution = None;
800 job.source_format = Some(hdr10_svtav1_format());
801 let args = build_encode_args(&job, EncodePass::Single).unwrap();
802 assert_eq!(args.iter().filter(|a| *a == "-svtav1-params").count(), 1);
804 let params = args
805 .windows(2)
806 .find(|w| w[0] == "-svtav1-params")
807 .map(|w| w[1].clone())
808 .expect("svtav1-params present");
809 assert!(params.contains("enable-adaptive-quantization=0"), "got: {params}");
810 assert!(params.contains("mastering-display=G(0.265,0.69)"), "got: {params}");
811 assert!(params.contains("content-light=1000,400"), "got: {params}");
812 }
813
814 #[test]
816 fn test_build_encode_args_preserves_10bit_source() {
817 let mut job = sample_job(RateControlMode::Crf);
818 job.codec = Codec::X265;
819 job.source_format = Some(SourceFormat {
820 pix_fmt: "yuv420p10le".into(),
821 bit_depth: 10,
822 color_primaries: "bt709".into(),
823 color_transfer: "bt709".into(),
824 color_space: "bt709".into(),
825 is_hdr: false,
826 hdr10: None,
827 });
828 let args = build_encode_args(&job, EncodePass::Single).unwrap();
829 assert!(has_pair(&args, "-pix_fmt", "yuv420p10le"));
830 assert!(args.iter().any(|a| a.contains("profile=main10")));
831 }
832
833 #[test]
834 fn test_build_encode_args_crf_single_pass() {
835 let args =
836 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
837 assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
838 assert_eq!(args.last().unwrap(), "output.mp4");
839 }
840
841 #[test]
842 fn test_x264_crf_args() {
843 let args = build_encode_args(
844 &job_with_codec(Codec::X264, RateControlMode::Crf),
845 EncodePass::Single,
846 )
847 .unwrap();
848 assert!(has_pair(&args, "-c:v", "libx264"));
849 assert!(has_pair(&args, "-crf", "23"));
850 assert!(has_pair(&args, "-preset", "medium"));
851 }
852
853 #[test]
854 fn test_x265_crf_args() {
855 let args = build_encode_args(
856 &job_with_codec(Codec::X265, RateControlMode::Crf),
857 EncodePass::Single,
858 )
859 .unwrap();
860 assert!(has_pair(&args, "-c:v", "libx265"));
861 assert!(has_pair(&args, "-crf", "23"));
862 }
863
864 #[test]
865 fn test_svtav1_crf_args() {
866 let args = build_encode_args(
867 &job_with_codec(Codec::SvtAv1, RateControlMode::Crf),
868 EncodePass::Single,
869 )
870 .unwrap();
871 assert!(has_pair(&args, "-c:v", "libsvtav1"));
872 assert!(has_pair(&args, "-crf", "23"));
873 }
874
875 #[test]
876 fn test_vp9_crf_args() {
877 let args = build_encode_args(
878 &job_with_codec(Codec::Vp9, RateControlMode::Crf),
879 EncodePass::Single,
880 )
881 .unwrap();
882 assert!(has_pair(&args, "-c:v", "libvpx-vp9"));
883 assert!(has_pair(&args, "-crf", "23"));
884 assert!(has_pair(&args, "-cpu-used", "2"));
885 assert!(has_pair(&args, "-deadline", "good"));
886 assert!(has_pair(&args, "-row-mt", "1"));
887 assert!(!has_arg(&args, "-preset"));
888 }
889
890 #[test]
891 fn test_vp9_capped_crf_uses_constrained_quality() {
892 let mut job = job_with_codec(Codec::Vp9, RateControlMode::CappedCrf);
893 job.max_bitrate = 2000.0;
894 let args = build_encode_args(&job, EncodePass::Single).unwrap();
895 assert!(has_pair(&args, "-crf", "23"));
896 assert!(has_pair(&args, "-b:v", "2000k"));
897 assert!(has_pair(&args, "-deadline", "good"));
898 assert!(!has_arg(&args, "-maxrate"));
899 }
900
901 #[test]
903 fn test_x264_qp_args() {
904 let args = build_encode_args(
905 &job_with_codec(Codec::X264, RateControlMode::Qp),
906 EncodePass::Single,
907 )
908 .unwrap();
909 assert!(has_pair(&args, "-qp", "23"));
910 assert!(!has_arg(&args, "-crf"));
911 }
912
913 #[test]
914 fn test_x265_qp_args() {
915 let args = build_encode_args(
916 &job_with_codec(Codec::X265, RateControlMode::Qp),
917 EncodePass::Single,
918 )
919 .unwrap();
920 assert!(has_pair(&args, "-qp", "23"));
921 }
922
923 #[test]
924 fn test_svtav1_qp_adds_adaptive_quantization_off() {
925 let args = build_encode_args(
926 &job_with_codec(Codec::SvtAv1, RateControlMode::Qp),
927 EncodePass::Single,
928 )
929 .unwrap();
930 assert!(has_pair(&args, "-qp", "23"));
931 assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"));
932 }
933
934 #[test]
936 fn test_build_encode_args_capped_crf_sets_vbv() {
937 let args =
938 build_encode_args(&sample_job(RateControlMode::CappedCrf), EncodePass::Single).unwrap();
939 assert!(args.windows(2).any(|w| w == ["-crf", "23"]));
940 assert!(args.windows(2).any(|w| w == ["-maxrate", "3000k"]));
941 assert!(args.windows(2).any(|w| w == ["-bufsize", "6000k"]));
942 }
943
944 #[test]
945 fn test_capped_crf_max_bitrate_zero_errors() {
946 let job = EncodeJob {
947 max_bitrate: 0.0,
948 rate_control: RateControlMode::CappedCrf,
949 ..sample_job(RateControlMode::CappedCrf)
950 };
951 assert!(build_encode_args(&job, EncodePass::Single).is_err());
952 }
953
954 #[test]
955 fn test_capped_crf_max_bitrate_negative_errors() {
956 let job = EncodeJob {
957 max_bitrate: -1.0,
958 rate_control: RateControlMode::CappedCrf,
959 ..sample_job(RateControlMode::CappedCrf)
960 };
961 assert!(build_encode_args(&job, EncodePass::Single).is_err());
962 }
963
964 #[test]
965 fn test_capped_crf_auto_bufsize_when_zero() {
966 let job = EncodeJob {
967 max_bitrate: 4000.0,
968 bufsize: 0.0,
969 rate_control: RateControlMode::CappedCrf,
970 ..sample_job(RateControlMode::CappedCrf)
971 };
972 let args = build_encode_args(&job, EncodePass::Single).unwrap();
973 assert!(has_pair(&args, "-bufsize", "8000k"));
974 }
975
976 #[test]
978 fn test_vbr_target_bitrate_zero_errors() {
979 let job = EncodeJob {
980 target_bitrate: 0.0,
981 rate_control: RateControlMode::Vbr,
982 ..sample_job(RateControlMode::Vbr)
983 };
984 assert!(build_encode_args(&job, EncodePass::First(Path::new("passlog"))).is_err());
985 }
986
987 #[test]
988 fn test_vbr_single_pass_errors() {
989 assert!(build_encode_args(&sample_job(RateControlMode::Vbr), EncodePass::Single).is_err());
990 }
991
992 #[test]
993 fn test_build_encode_args_vbr_first_pass_uses_null_output() {
994 let job = sample_job(RateControlMode::Vbr);
995 let passlog = Path::new("/tmp/viser-passlog");
996 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
997 assert!(args.windows(2).any(|w| w == ["-pass", "1"]));
998 assert!(args.windows(2).any(|w| w == ["-f", "null"]));
999 assert_eq!(args.last().unwrap(), null_output_path());
1000 }
1001
1002 #[test]
1003 fn test_build_encode_args_vbr_second_pass_writes_output() {
1004 let job = sample_job(RateControlMode::Vbr);
1005 let passlog = Path::new("/tmp/viser-passlog");
1006 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1007 assert!(args.windows(2).any(|w| w == ["-pass", "2"]));
1008 assert_eq!(args.last().unwrap(), "output.mp4");
1009 }
1010
1011 #[test]
1012 fn test_vbr_first_pass_no_progress_args() {
1013 let job = sample_job(RateControlMode::Vbr);
1014 let passlog = Path::new("/tmp/viser-passlog");
1015 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
1016 assert!(!has_arg(&args, "-progress"));
1017 assert!(!has_arg(&args, "-nostats"));
1018 }
1019
1020 #[test]
1021 fn test_vbr_second_pass_sets_bitrate_and_vbv() {
1022 let job = sample_job(RateControlMode::Vbr);
1023 let passlog = Path::new("/tmp/viser-passlog");
1024 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1025 assert!(has_pair(&args, "-b:v", "2500k"));
1026 assert!(has_pair(&args, "-maxrate", "5000k"));
1027 assert!(has_pair(&args, "-bufsize", "10000k"));
1028 }
1029
1030 #[test]
1031 fn test_vbr_sets_passlog() {
1032 let job = sample_job(RateControlMode::Vbr);
1033 let passlog = Path::new("/tmp/viser-passlog");
1034 let args = build_encode_args(&job, EncodePass::First(passlog)).unwrap();
1035 assert!(has_arg(&args, "/tmp/viser-passlog"));
1036 }
1037
1038 #[test]
1040 fn test_resolution_scaling_adds_vf() {
1041 let args =
1042 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1043 assert!(has_arg(&args, "-vf"));
1044 assert!(has_arg(&args, "scale=1280:720:flags=lanczos"));
1045 }
1046
1047 #[test]
1048 fn test_zero_width_skips_scale() {
1049 let job = EncodeJob {
1050 resolution: Some(crate::Resolution::new(0, 720)),
1051 ..sample_job(RateControlMode::Crf)
1052 };
1053 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1054 assert!(!has_arg(&args, "-vf"));
1055 }
1056
1057 #[test]
1058 fn test_zero_height_skips_scale() {
1059 let job = EncodeJob {
1060 resolution: Some(crate::Resolution::new(1280, 0)),
1061 ..sample_job(RateControlMode::Crf)
1062 };
1063 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1064 assert!(!has_arg(&args, "-vf"));
1065 }
1066
1067 #[test]
1068 fn test_no_resolution_skips_scale() {
1069 let job = EncodeJob { resolution: None, ..sample_job(RateControlMode::Crf) };
1070 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1071 assert!(!has_arg(&args, "-vf"));
1072 }
1073
1074 #[test]
1075 fn test_resolution_negative_skip_scale() {
1076 let job = EncodeJob {
1077 resolution: Some(crate::Resolution::new(-1, -1)),
1078 ..sample_job(RateControlMode::Crf)
1079 };
1080 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1081 assert!(!has_arg(&args, "-vf"));
1082 }
1083
1084 #[test]
1086 fn test_empty_preset_no_preset_arg() {
1087 let job = EncodeJob { preset: String::new(), ..sample_job(RateControlMode::Crf) };
1088 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1089 assert!(!has_arg(&args, "-preset"));
1090 }
1091
1092 #[test]
1093 fn test_preset_with_x264() {
1094 let job = EncodeJob {
1095 codec: Codec::X264,
1096 preset: "fast".into(),
1097 ..sample_job(RateControlMode::Crf)
1098 };
1099 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1100 assert!(has_pair(&args, "-preset", "fast"));
1101 }
1102
1103 #[test]
1105 fn test_extra_args_appended_before_output() {
1106 let job = EncodeJob {
1107 extra_args: vec!["-g".into(), "30".into(), "-bf".into(), "2".into()],
1108 ..sample_job(RateControlMode::Crf)
1109 };
1110 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1111 assert!(has_pair(&args, "-g", "30"));
1112 assert!(has_pair(&args, "-bf", "2"));
1113 assert_eq!(args.last().unwrap(), "output.mp4");
1114 }
1115
1116 #[test]
1118 fn test_null_output_path_is_platform_appropriate() {
1119 let null = null_output_path();
1120 assert!(!null.is_empty());
1121 assert!(null == "/dev/null" || null == "NUL");
1122 }
1123
1124 #[test]
1126 fn test_parse_progress_line_frame() {
1127 let mut p = Progress::default();
1128 assert!(!parse_progress_line("frame=100", &mut p));
1129 assert_eq!(p.frame, 100);
1130 }
1131
1132 #[test]
1133 fn test_parse_progress_line_fps() {
1134 let mut p = Progress::default();
1135 parse_progress_line("fps=23.976", &mut p);
1136 assert!((p.fps - 23.976).abs() < 0.001);
1137 }
1138
1139 #[test]
1140 fn test_parse_progress_line_bitrate() {
1141 let mut p = Progress::default();
1142 parse_progress_line("bitrate=1500.5kbits/s", &mut p);
1143 assert!((p.bitrate - 1500.5).abs() < 0.001);
1144 }
1145
1146 #[test]
1147 fn test_parse_progress_line_speed() {
1148 let mut p = Progress::default();
1149 parse_progress_line("speed=1.5x", &mut p);
1150 assert!((p.speed - 1.5).abs() < 0.001);
1151 }
1152
1153 #[test]
1154 fn test_parse_progress_line_out_time_us() {
1155 let mut p = Progress::default();
1156 parse_progress_line("out_time_us=1234567", &mut p);
1157 assert_eq!(p.time, Duration::from_micros(1234567));
1158 }
1159
1160 #[test]
1161 fn test_parse_progress_returns_true_on_progress() {
1162 let mut p = Progress::default();
1163 assert!(parse_progress_line("progress=continue", &mut p));
1164 }
1165
1166 #[test]
1167 fn test_parse_progress_full_block() {
1168 let mut p = Progress::default();
1169 parse_progress_line("frame=1500", &mut p);
1170 parse_progress_line("fps=25.0", &mut p);
1171 parse_progress_line("bitrate=2000.0kbits/s", &mut p);
1172 parse_progress_line("speed=2.0x", &mut p);
1173 parse_progress_line("out_time_us=60000000", &mut p);
1174 assert!(parse_progress_line("progress=continue", &mut p));
1175 assert_eq!(p.frame, 1500);
1176 assert_eq!(p.time, Duration::from_secs(60));
1177 }
1178
1179 #[test]
1180 fn test_parse_progress_line_missing_equals() {
1181 let mut p = Progress::default();
1182 assert!(!parse_progress_line("noequals", &mut p));
1183 }
1184
1185 #[test]
1186 fn test_parse_progress_line_unknown_key() {
1187 let mut p = Progress::default();
1188 assert!(!parse_progress_line("unknown=42", &mut p));
1189 }
1190
1191 #[test]
1192 fn test_parse_progress_line_bogus_numbers() {
1193 let mut p = Progress::default();
1194 parse_progress_line("frame=abc", &mut p);
1195 assert_eq!(p.frame, 0);
1196 }
1197
1198 #[test]
1200 fn test_make_passlog_prefix_uses_output_dir() {
1201 let prefix = make_passlog_prefix("/path/to/video.mp4");
1202 assert!(prefix.starts_with(Path::new("/path/to")));
1203 assert!(prefix.to_string_lossy().contains("video"));
1204 }
1205
1206 #[test]
1207 fn test_make_passlog_prefix_no_parent_falls_back_to_cwd() {
1208 let prefix = make_passlog_prefix("video.mp4");
1209 assert!(prefix.starts_with(Path::new(".")));
1210 }
1211
1212 #[test]
1214 fn test_make_concat_list_path_is_txt() {
1215 let path = make_concat_list_path("output.mp4");
1216 assert!(path.to_string_lossy().ends_with(".txt"));
1217 }
1218
1219 #[test]
1221 fn test_escape_concat_path_escapes_single_quotes() {
1222 assert_eq!(escape_concat_path("video's.mp4"), "video\\'s.mp4");
1223 }
1224
1225 #[test]
1226 fn test_escape_concat_path_escapes_backslashes() {
1227 assert_eq!(escape_concat_path("dir\\video.mp4"), "dir\\\\video.mp4");
1228 }
1229
1230 #[test]
1231 fn test_escape_concat_path_no_change_for_simple_paths() {
1232 assert_eq!(escape_concat_path("/tmp/video.mp4"), "/tmp/video.mp4");
1233 }
1234
1235 #[tokio::test]
1237 async fn test_extract_rejects_negative_start() {
1238 let err = extract("in.mp4", "out.mp4", -1.0, 5.0).await.unwrap_err();
1239 assert!(err.to_string().contains("start must be non-negative"));
1240 }
1241
1242 #[tokio::test]
1243 async fn test_extract_rejects_zero_duration() {
1244 let err = extract("in.mp4", "out.mp4", 0.0, 0.0).await.unwrap_err();
1245 assert!(err.to_string().contains("duration must be positive"));
1246 }
1247
1248 #[tokio::test]
1249 async fn test_extract_rejects_negative_duration() {
1250 let err = extract("in.mp4", "out.mp4", 0.0, -5.0).await.unwrap_err();
1251 assert!(err.to_string().contains("duration must be positive"));
1252 }
1253
1254 #[tokio::test]
1255 async fn test_extract_rejects_nan_duration() {
1256 let err = extract("in.mp4", "out.mp4", 0.0, f64::NAN).await.unwrap_err();
1257 assert!(err.to_string().contains("duration must be positive"));
1258 }
1259
1260 fn hw_crf(codec: Codec) -> EncodeJob {
1262 EncodeJob {
1263 codec,
1264 preset: String::new(),
1265 resolution: None,
1266 extra_args: vec![],
1267 ..sample_job(RateControlMode::Crf)
1268 }
1269 }
1270
1271 fn hw_qp(codec: Codec) -> EncodeJob {
1272 EncodeJob {
1273 codec,
1274 preset: String::new(),
1275 resolution: None,
1276 extra_args: vec![],
1277 ..sample_job(RateControlMode::Qp)
1278 }
1279 }
1280
1281 #[test]
1283 fn test_nvenc_h264_crf_uses_constqp() {
1284 let args = build_encode_args(&hw_crf(Codec::NvencH264), EncodePass::Single).unwrap();
1285 assert!(has_pair(&args, "-rc", "constqp"));
1286 assert!(has_arg(&args, "-cq"));
1287 }
1288
1289 #[test]
1290 fn test_nvenc_h265_crf_uses_constqp() {
1291 let args = build_encode_args(&hw_crf(Codec::NvencH265), EncodePass::Single).unwrap();
1292 assert!(has_pair(&args, "-rc", "constqp"));
1293 assert!(has_arg(&args, "-cq"));
1294 }
1295
1296 #[test]
1297 fn test_qsv_h264_crf_uses_global_quality() {
1298 let args = build_encode_args(&hw_crf(Codec::QsvH264), EncodePass::Single).unwrap();
1299 assert!(has_arg(&args, "-global_quality"));
1300 }
1301
1302 #[test]
1303 fn test_qsv_h265_crf_uses_global_quality() {
1304 let args = build_encode_args(&hw_crf(Codec::QsvH265), EncodePass::Single).unwrap();
1305 assert!(has_arg(&args, "-global_quality"));
1306 }
1307
1308 #[test]
1309 fn test_vt_h264_crf_uses_quality() {
1310 let args = build_encode_args(&hw_crf(Codec::VideoToolboxH264), EncodePass::Single).unwrap();
1311 assert!(has_arg(&args, "-quality"));
1312 }
1313
1314 #[test]
1315 fn test_vt_h265_crf_uses_quality() {
1316 let args = build_encode_args(&hw_crf(Codec::VideoToolboxH265), EncodePass::Single).unwrap();
1317 assert!(has_arg(&args, "-quality"));
1318 }
1319
1320 #[test]
1321 fn test_vaapi_h264_crf_uses_global_quality() {
1322 let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1323 assert!(has_arg(&args, "-global_quality"));
1324 }
1325
1326 #[test]
1327 fn test_vaapi_h265_crf_uses_global_quality() {
1328 let args = build_encode_args(&hw_crf(Codec::VaapiH265), EncodePass::Single).unwrap();
1329 assert!(has_arg(&args, "-global_quality"));
1330 }
1331
1332 #[test]
1333 fn test_amf_h264_crf_uses_qp_and_usage() {
1334 let args = build_encode_args(&hw_crf(Codec::AmfH264), EncodePass::Single).unwrap();
1335 assert!(has_pair(&args, "-qp_i", "23"));
1336 assert!(has_pair(&args, "-qp_p", "25"));
1337 assert!(has_pair(&args, "-usage", "transcoding"));
1338 }
1339
1340 #[test]
1341 fn test_amf_h265_crf_uses_qp_and_usage() {
1342 let args = build_encode_args(&hw_crf(Codec::AmfH265), EncodePass::Single).unwrap();
1343 assert!(has_pair(&args, "-qp_i", "23"));
1344 assert!(has_pair(&args, "-qp_p", "25"));
1345 assert!(has_pair(&args, "-usage", "transcoding"));
1346 }
1347
1348 #[test]
1350 fn test_nvenc_h264_qp() {
1351 let args = build_encode_args(&hw_qp(Codec::NvencH264), EncodePass::Single).unwrap();
1352 assert!(has_pair(&args, "-qp", "23"));
1353 }
1354
1355 #[test]
1356 fn test_qsv_h264_qp() {
1357 let args = build_encode_args(&hw_qp(Codec::QsvH264), EncodePass::Single).unwrap();
1358 assert!(has_pair(&args, "-qp", "23"));
1359 }
1360
1361 #[test]
1362 fn test_vaapi_h264_qp() {
1363 let args = build_encode_args(&hw_qp(Codec::VaapiH264), EncodePass::Single).unwrap();
1364 assert!(has_pair(&args, "-qp", "23"));
1365 }
1366
1367 #[test]
1368 fn test_amf_h264_qp() {
1369 let args = build_encode_args(&hw_qp(Codec::AmfH264), EncodePass::Single).unwrap();
1370 assert!(has_pair(&args, "-qp", "23"));
1371 }
1372
1373 #[test]
1374 fn test_vt_qp_rejected() {
1375 let result = build_encode_args(&hw_qp(Codec::VideoToolboxH264), EncodePass::Single);
1376 assert!(result.is_err());
1377 }
1378
1379 #[test]
1380 fn test_vt_h265_qp_rejected() {
1381 let result = build_encode_args(&hw_qp(Codec::VideoToolboxH265), EncodePass::Single);
1382 assert!(result.is_err());
1383 }
1384
1385 #[test]
1387 fn test_nvenc_capped_crf_sets_vbv() {
1388 let job = EncodeJob {
1389 codec: Codec::NvencH264,
1390 max_bitrate: 5000.0,
1391 bufsize: 10000.0,
1392 rate_control: RateControlMode::CappedCrf,
1393 ..sample_job(RateControlMode::Crf)
1394 };
1395 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1396 assert!(has_pair(&args, "-rc", "vbr"));
1398 assert!(has_pair(&args, "-maxrate", "5000k"));
1399 assert!(has_pair(&args, "-bufsize", "10000k"));
1400 }
1401
1402 #[test]
1403 fn test_hw_capped_crf_max_bitrate_zero_errors() {
1404 let job = EncodeJob {
1405 codec: Codec::NvencH264,
1406 max_bitrate: 0.0,
1407 rate_control: RateControlMode::CappedCrf,
1408 ..sample_job(RateControlMode::Crf)
1409 };
1410 assert!(build_encode_args(&job, EncodePass::Single).is_err());
1411 }
1412
1413 #[test]
1415 fn test_nvenc_vbr_uses_vbr_hq() {
1416 let job = EncodeJob {
1417 codec: Codec::NvencH264,
1418 target_bitrate: 5000.0,
1419 rate_control: RateControlMode::Vbr,
1420 ..sample_job(RateControlMode::Vbr)
1421 };
1422 let passlog = Path::new("/tmp/plog");
1423 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1424 assert!(has_pair(&args, "-rc", "vbr_hq"));
1425 }
1426
1427 #[test]
1428 fn test_qsv_vbr_no_special_rc() {
1429 let job = EncodeJob {
1430 codec: Codec::QsvH264,
1431 target_bitrate: 5000.0,
1432 rate_control: RateControlMode::Vbr,
1433 ..sample_job(RateControlMode::Vbr)
1434 };
1435 let passlog = Path::new("/tmp/plog");
1436 let args = build_encode_args(&job, EncodePass::Second(passlog)).unwrap();
1437 assert!(!has_arg(&args, "-rc"));
1438 }
1439
1440 #[test]
1441 fn test_hw_vbr_target_bitrate_zero_errors() {
1442 let job = EncodeJob {
1443 codec: Codec::NvencH264,
1444 target_bitrate: 0.0,
1445 rate_control: RateControlMode::Vbr,
1446 ..sample_job(RateControlMode::Vbr)
1447 };
1448 let passlog = Path::new("/tmp/plog");
1449 assert!(build_encode_args(&job, EncodePass::Second(passlog)).is_err());
1450 }
1451
1452 #[test]
1454 fn test_nvenc_preset_maps_to_p_numbers() {
1455 let job = EncodeJob {
1456 codec: Codec::NvencH264,
1457 preset: "veryfast".into(),
1458 ..sample_job(RateControlMode::Crf)
1459 };
1460 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1461 assert!(has_pair(&args, "-preset", "p2"));
1462 }
1463
1464 #[test]
1465 fn test_vaapi_preset_uses_compression_level() {
1466 let job = EncodeJob {
1467 codec: Codec::VaapiH264,
1468 preset: "medium".into(),
1469 ..sample_job(RateControlMode::Crf)
1470 };
1471 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1472 assert!(has_pair(&args, "-compression_level", "3"));
1473 }
1474
1475 #[test]
1476 fn test_amf_preset_uses_quality() {
1477 let job = EncodeJob {
1478 codec: Codec::AmfH264,
1479 preset: "slow".into(),
1480 ..sample_job(RateControlMode::Crf)
1481 };
1482 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1483 assert!(has_pair(&args, "-quality", "quality"));
1484 }
1485
1486 #[test]
1487 fn test_amf_preset_speed() {
1488 let job = EncodeJob {
1489 codec: Codec::AmfH264,
1490 preset: "ultrafast".into(),
1491 ..sample_job(RateControlMode::Crf)
1492 };
1493 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1494 assert!(has_pair(&args, "-quality", "speed"));
1495 }
1496
1497 #[test]
1499 fn test_av1_hw_codecs_have_correct_codec_string() {
1500 for codec in &[Codec::NvencAv1, Codec::QsvAv1, Codec::VaapiAv1, Codec::AmfAv1] {
1501 let job = EncodeJob {
1502 codec: *codec,
1503 preset: String::new(),
1504 resolution: None,
1505 extra_args: vec![],
1506 ..sample_job(RateControlMode::Crf)
1507 };
1508 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1509 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1510 }
1511 }
1512
1513 #[test]
1514 fn test_av1_nvenc_crf_uses_constqp() {
1515 let args = build_encode_args(&hw_crf(Codec::NvencAv1), EncodePass::Single).unwrap();
1516 assert!(has_pair(&args, "-rc", "constqp"));
1517 assert!(has_arg(&args, "-cq"));
1518 }
1519
1520 #[test]
1521 fn test_av1_vaapi_crf_uses_global_quality() {
1522 let args = build_encode_args(&hw_crf(Codec::VaapiAv1), EncodePass::Single).unwrap();
1523 assert!(has_arg(&args, "-global_quality"));
1524 }
1525
1526 #[test]
1528 fn test_vaapi_sets_device_before_input() {
1529 let args = build_encode_args(&hw_crf(Codec::VaapiH264), EncodePass::Single).unwrap();
1530 let dev_idx =
1531 args.iter().position(|a| a == "-vaapi_device").expect("missing -vaapi_device");
1532 let i_idx = args.iter().position(|a| a == "-i").expect("missing -i");
1533 assert!(dev_idx < i_idx, "-vaapi_device must precede -i: {args:?}");
1534 }
1535
1536 #[test]
1537 fn test_vaapi_filter_chain_has_hwupload() {
1538 let job = EncodeJob { codec: Codec::VaapiH264, ..sample_job(RateControlMode::Crf) };
1540 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1541 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1542 let vf = &args[vf_idx + 1];
1543 assert!(vf.contains("scale=1280:720:flags=lanczos"), "missing scale: {vf}");
1544 assert!(vf.contains("format=nv12,hwupload"), "missing hwupload: {vf}");
1545 assert_eq!(args.iter().filter(|a| *a == "-vf").count(), 1, "exactly one -vf: {args:?}");
1546 }
1547
1548 #[test]
1549 fn test_vaapi_hwupload_present_without_resolution() {
1550 let job = EncodeJob {
1551 codec: Codec::VaapiH264,
1552 resolution: None,
1553 ..sample_job(RateControlMode::Crf)
1554 };
1555 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1556 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1557 assert_eq!(args[vf_idx + 1], "format=nv12,hwupload");
1558 }
1559
1560 #[test]
1561 fn test_non_vaapi_has_no_hwupload_or_device() {
1562 let job = EncodeJob { codec: Codec::NvencH264, ..sample_job(RateControlMode::Crf) };
1563 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1564 assert!(!has_arg(&args, "-vaapi_device"));
1565 let vf_idx = args.iter().position(|a| a == "-vf").expect("missing -vf");
1566 assert_eq!(args[vf_idx + 1], "scale=1280:720:flags=lanczos");
1567 }
1568
1569 #[test]
1571 fn test_hwaccel_injected_before_input() {
1572 let job = EncodeJob {
1573 codec: Codec::X264,
1574 hwaccel: Some("cuda".into()),
1575 ..sample_job(RateControlMode::Crf)
1576 };
1577 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1578 let acc_idx = args.iter().position(|a| a == "-hwaccel").expect("missing -hwaccel");
1579 let i_idx = args.iter().position(|a| a == "-i").expect("missing -i");
1580 assert_eq!(args[acc_idx + 1], "cuda");
1581 assert!(acc_idx < i_idx, "-hwaccel must precede -i: {args:?}");
1582 }
1583
1584 #[test]
1585 fn test_no_hwaccel_when_unset_or_empty() {
1586 for hw in [None, Some(String::new())] {
1587 let job =
1588 EncodeJob { codec: Codec::X264, hwaccel: hw, ..sample_job(RateControlMode::Crf) };
1589 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1590 assert!(!has_arg(&args, "-hwaccel"), "unexpected -hwaccel: {args:?}");
1591 }
1592 }
1593
1594 #[test]
1595 fn test_amf_preset_balanced() {
1596 let job = EncodeJob {
1597 codec: Codec::AmfH264,
1598 preset: "fast".into(),
1599 ..sample_job(RateControlMode::Crf)
1600 };
1601 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1602 assert!(has_pair(&args, "-quality", "balanced"));
1603 }
1604
1605 #[test]
1606 fn test_vt_preset_realtime_for_ultrafast() {
1607 let job = EncodeJob {
1608 codec: Codec::VideoToolboxH264,
1609 preset: "ultrafast".into(),
1610 ..sample_job(RateControlMode::Crf)
1611 };
1612 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1613 assert!(has_pair(&args, "-realtime", "1"));
1614 }
1615
1616 #[test]
1617 fn test_vt_preset_realtime_for_veryfast() {
1618 let job = EncodeJob {
1619 codec: Codec::VideoToolboxH264,
1620 preset: "veryfast".into(),
1621 ..sample_job(RateControlMode::Crf)
1622 };
1623 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1624 assert!(has_pair(&args, "-realtime", "1"));
1625 }
1626
1627 #[test]
1628 fn test_vt_preset_no_realtime_for_slow() {
1629 let job = EncodeJob {
1630 codec: Codec::VideoToolboxH264,
1631 preset: "slow".into(),
1632 ..sample_job(RateControlMode::Crf)
1633 };
1634 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1635 assert!(!has_arg(&args, "-realtime"));
1636 }
1637
1638 #[test]
1639 fn test_qsv_preset_passthrough() {
1640 let job = EncodeJob {
1641 codec: Codec::QsvH264,
1642 preset: "medium".into(),
1643 ..sample_job(RateControlMode::Crf)
1644 };
1645 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1646 assert!(has_pair(&args, "-preset", "medium"));
1647 }
1648
1649 #[test]
1651 fn test_crf_to_nvenc_cq_bounds() {
1652 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); }
1657
1658 #[test]
1659 fn test_crf_to_nvenc_cq_typical_values() {
1660 assert_eq!(crf_to_nvenc_cq(23), 18); assert_eq!(crf_to_nvenc_cq(30), 24); }
1663
1664 #[test]
1665 fn test_crf_to_qsv_quality_bounds() {
1666 let q0 = crf_to_qsv_quality(0);
1667 assert!((95..=100).contains(&q0)); let q51 = crf_to_qsv_quality(51);
1669 assert_eq!(q51, 1); let q100 = crf_to_qsv_quality(100);
1671 assert_eq!(q100, 1); }
1673
1674 #[test]
1675 fn test_crf_to_qsv_quality_mid() {
1676 let q = crf_to_qsv_quality(25);
1677 assert!((50..=52).contains(&q));
1679 }
1680
1681 #[test]
1682 fn test_crf_to_vt_quality_bounds() {
1683 assert!((crf_to_vt_quality(0) - 1.0).abs() < 1e-9);
1684 assert!((crf_to_vt_quality(51) - 0.0).abs() < 1e-9);
1685 assert!((crf_to_vt_quality(100) - 0.0).abs() < 1e-9);
1686 }
1687
1688 #[test]
1689 fn test_crf_to_vt_quality_mid() {
1690 let q = crf_to_vt_quality(25);
1691 assert!(q > 0.4 && q < 0.6);
1692 }
1693
1694 #[test]
1696 fn test_crf_zero() {
1697 let job = EncodeJob { crf: 0, ..sample_job(RateControlMode::Crf) };
1698 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1699 assert!(has_pair(&args, "-crf", "0"));
1700 }
1701
1702 #[test]
1703 fn test_crf_high_value() {
1704 let job = EncodeJob { crf: 51, ..sample_job(RateControlMode::Crf) };
1705 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1706 assert!(has_pair(&args, "-crf", "51"));
1707 }
1708
1709 #[test]
1710 fn test_crf_negative_allowed() {
1711 let job = EncodeJob { crf: -1, ..sample_job(RateControlMode::Crf) };
1712 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1713 assert!(has_pair(&args, "-crf", "-1"));
1714 }
1715
1716 #[test]
1717 fn test_input_arg_is_present() {
1718 let args =
1719 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1720 assert!(has_pair(&args, "-i", "input.mp4"));
1721 }
1722
1723 #[test]
1724 fn test_no_audio_flag_is_present() {
1725 let args =
1726 build_encode_args(&sample_job(RateControlMode::Crf), EncodePass::Single).unwrap();
1727 assert!(has_arg(&args, "-an"));
1728 }
1729
1730 #[test]
1732 fn test_all_sw_codecs_have_correct_codec_string() {
1733 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1, Codec::Vp9] {
1734 let job = EncodeJob {
1735 codec: *codec,
1736 preset: String::new(),
1737 resolution: None,
1738 extra_args: vec![],
1739 ..sample_job(RateControlMode::Crf)
1740 };
1741 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1742 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1743 }
1744 }
1745
1746 #[test]
1747 fn test_all_hw_codecs_have_correct_codec_string() {
1748 for codec in &[
1749 Codec::NvencH264,
1750 Codec::NvencH265,
1751 Codec::QsvH264,
1752 Codec::QsvH265,
1753 Codec::VideoToolboxH264,
1754 Codec::VideoToolboxH265,
1755 Codec::VaapiH264,
1756 Codec::VaapiH265,
1757 Codec::AmfH264,
1758 Codec::AmfH265,
1759 ] {
1760 let job = EncodeJob {
1761 codec: *codec,
1762 preset: String::new(),
1763 resolution: None,
1764 extra_args: vec![],
1765 ..sample_job(RateControlMode::Crf)
1766 };
1767 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1768 assert!(has_pair(&args, "-c:v", codec.as_str()), "expected -c:v {}", codec.as_str());
1769 }
1770 }
1771
1772 #[cfg(test)]
1774 mod proptests {
1775 use super::*;
1776 use proptest::prelude::*;
1777
1778 fn arb_codec() -> impl Strategy<Value = Codec> {
1779 prop_oneof![
1780 Just(Codec::X264),
1781 Just(Codec::X265),
1782 Just(Codec::SvtAv1),
1783 Just(Codec::Vp9),
1784 Just(Codec::NvencH264),
1785 Just(Codec::NvencH265),
1786 Just(Codec::QsvH264),
1787 Just(Codec::QsvH265),
1788 Just(Codec::VideoToolboxH264),
1789 Just(Codec::VideoToolboxH265),
1790 Just(Codec::VaapiH264),
1791 Just(Codec::VaapiH265),
1792 Just(Codec::AmfH264),
1793 Just(Codec::AmfH265),
1794 Just(Codec::NvencAv1),
1795 Just(Codec::QsvAv1),
1796 Just(Codec::VaapiAv1),
1797 Just(Codec::AmfAv1),
1798 ]
1799 }
1800
1801 fn arb_rate_control() -> impl Strategy<Value = RateControlMode> {
1802 prop_oneof![
1803 Just(RateControlMode::Crf),
1804 Just(RateControlMode::Qp),
1805 Just(RateControlMode::CappedCrf),
1806 ]
1807 }
1808
1809 fn arb_encode_job() -> impl Strategy<Value = EncodeJob> {
1810 (
1811 arb_codec(),
1812 arb_rate_control(),
1813 any::<i32>(),
1814 any::<f64>(),
1815 any::<f64>(),
1816 any::<f64>(),
1817 any::<String>(),
1818 )
1819 .prop_map(|(codec, rc, crf, target_br, max_br, bufsize, preset)| {
1820 let crf = crf.abs().min(63);
1821 EncodeJob {
1822 input: "input.mp4".into(),
1823 output: "output.mp4".into(),
1824 resolution: Some(Resolution::new(1920, 1080)),
1825 codec,
1826 crf,
1827 rate_control: rc,
1828 target_bitrate: target_br.abs().min(100000.0),
1829 max_bitrate: max_br.abs().min(100000.0),
1830 bufsize: bufsize.abs().min(200000.0),
1831 preset,
1832 hwaccel: None,
1833 extra_args: vec![],
1834 source_format: None,
1835 }
1836 })
1837 }
1838
1839 proptest! {
1840 #[test]
1844 fn args_start_with_overwrite_and_input(job in arb_encode_job()) {
1845 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1846 assert!(args.len() >= 3, "too few args: {args:?}");
1847 assert_eq!(args[0], "-y", "first arg must be -y");
1848 let i_idx = args.iter().position(|a| a == "-i").expect("must contain -i");
1849 assert_eq!(args[i_idx + 1], "input.mp4", "input path must follow -i");
1850 }
1851 }
1852
1853 #[test]
1855 fn args_have_no_audio_flag(job in arb_encode_job()) {
1856 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1857 assert!(has_arg(&args, "-an"),
1858 "missing -an: {args:?}");
1859 }
1860 }
1861
1862 #[test]
1864 fn args_have_correct_codec(job in arb_encode_job()) {
1865 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1866 assert!(has_pair(&args, "-c:v", job.codec.as_str()),
1867 "missing or wrong -c:v: {args:?}, expected {}", job.codec.as_str());
1868 }
1869 }
1870
1871 #[test]
1873 fn output_is_the_last_argument(job in arb_encode_job()) {
1874 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1875 assert_eq!(args.last().unwrap(), "output.mp4",
1876 "output not last: {args:?}");
1877 }
1878 }
1879
1880 #[test]
1883 fn no_duplicate_flag_keys(job in arb_encode_job()) {
1884 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1885 let mut seen = std::collections::HashSet::new();
1886 for arg_chunk in args.chunks(2) {
1887 if arg_chunk[0].starts_with('-') {
1888 assert!(seen.insert(&arg_chunk[0]),
1889 "duplicate flag {} in {args:?}", arg_chunk[0]);
1890 }
1891 }
1892 }
1893 }
1894
1895 #[test]
1897 fn sw_crf_has_crf_flag(
1898 crf in 0i32..63i32,
1899 preset in ".*",
1900 ) {
1901 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1, Codec::Vp9] {
1902 let job = EncodeJob {
1903 codec: *codec, crf, rate_control: RateControlMode::Crf,
1904 preset: preset.clone(), resolution: None, extra_args: vec![],
1905 ..sample_job(RateControlMode::Crf)
1906 };
1907 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1908 assert!(has_pair(&args, "-crf", &crf.to_string()),
1909 "{codec:?}: missing -crf {crf} in {args:?}");
1910 }
1911 }
1912
1913 #[test]
1915 fn sw_crf_and_qp_never_both_present(
1916 crf in 0i32..63i32,
1917 mode in prop_oneof![Just(RateControlMode::Crf), Just(RateControlMode::Qp)],
1918 ) {
1919 for codec in &[Codec::X264, Codec::X265] {
1920 let job = EncodeJob {
1921 codec: *codec, crf, rate_control: mode, preset: String::new(),
1922 resolution: None, extra_args: vec![],
1923 ..sample_job(mode)
1924 };
1925 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1926 let has_crf = has_arg(&args, "-crf");
1927 let has_qp = has_arg(&args, "-qp");
1928 assert!(!(has_crf && has_qp),
1929 "{codec:?} mode={mode:?}: both -crf and -qp present: {args:?}");
1930 }
1931 }
1932 }
1933
1934 #[test]
1936 fn capped_crf_has_rate_control_args(job in arb_encode_job_sw_capped()) {
1937 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
1938 let maxrate_idx = args.iter().position(|a| a == "-maxrate");
1940 if let Some(idx) = maxrate_idx {
1941 let val = &args[idx + 1];
1942 assert!(val.ends_with('k'),
1943 "-maxrate value should end with 'k': {val}");
1944 }
1945 let bufsize_idx = args.iter().position(|a| a == "-bufsize");
1946 if let Some(idx) = bufsize_idx {
1947 let val = &args[idx + 1];
1948 assert!(val.ends_with('k'),
1949 "-bufsize value should end with 'k': {val}");
1950 }
1951 }
1952 }
1953
1954 #[test]
1956 fn vbr_first_pass_has_null_output(
1957 job in arb_encode_job_sw_vbr(),
1958 ) {
1959 let passlog = Path::new("/tmp/plog");
1960 if let Ok(args) = build_encode_args(&job, EncodePass::First(passlog)) {
1961 assert!(!has_arg(&args, "-progress"),
1962 "first pass should not have -progress: {args:?}");
1963 assert!(has_pair(&args, "-f", "null"),
1964 "first pass must write to null: {args:?}");
1965 }
1966 }
1967
1968 #[test]
1970 fn svtav1_qp_disables_aq(
1971 crf in 1i32..63i32,
1972 preset in ".*",
1973 ) {
1974 let job = EncodeJob {
1975 codec: Codec::SvtAv1, crf, rate_control: RateControlMode::Qp,
1976 preset, resolution: None, extra_args: vec![],
1977 ..sample_job(RateControlMode::Qp)
1978 };
1979 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1980 assert!(has_pair(&args, "-svtav1-params", "enable-adaptive-quantization=0"),
1981 "SVT-AV1 QP must disable adaptive quantization: {args:?}");
1982 }
1983
1984 #[test]
1986 fn nvenc_crf_uses_cq_not_crf(
1987 crf in 0i32..63i32,
1988 h264_h265 in prop_oneof![Just(Codec::NvencH264), Just(Codec::NvencH265)],
1989 ) {
1990 let job = EncodeJob {
1991 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
1992 preset: String::new(), resolution: None, extra_args: vec![],
1993 ..sample_job(RateControlMode::Crf)
1994 };
1995 let args = build_encode_args(&job, EncodePass::Single).unwrap();
1996 assert!(has_pair(&args, "-rc", "constqp"),
1997 "NVENC CRF missing -rc constqp: {args:?}");
1998 assert!(has_arg(&args, "-cq"),
1999 "NVENC CRF missing -cq: {args:?}");
2000 assert!(!has_arg(&args, "-crf"),
2001 "NVENC must not use -crf: {args:?}");
2002 }
2003
2004 #[test]
2006 fn qsv_crf_uses_global_quality(
2007 crf in 0i32..63i32,
2008 h264_h265 in prop_oneof![Just(Codec::QsvH264), Just(Codec::QsvH265)],
2009 ) {
2010 let job = EncodeJob {
2011 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
2012 preset: String::new(), resolution: None, extra_args: vec![],
2013 ..sample_job(RateControlMode::Crf)
2014 };
2015 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2016 assert!(has_arg(&args, "-global_quality"),
2017 "QSV CRF missing -global_quality: {args:?}");
2018 assert!(!has_arg(&args, "-crf"),
2019 "QSV must not use -crf: {args:?}");
2020 }
2021
2022 #[test]
2024 fn amf_crf_uses_qp_pairs(
2025 crf in 0i32..63i32,
2026 h264_h265 in prop_oneof![Just(Codec::AmfH264), Just(Codec::AmfH265)],
2027 ) {
2028 let job = EncodeJob {
2029 codec: h264_h265, crf, rate_control: RateControlMode::Crf,
2030 preset: String::new(), resolution: None, extra_args: vec![],
2031 ..sample_job(RateControlMode::Crf)
2032 };
2033 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2034 assert!(has_pair(&args, "-qp_i", &crf.to_string()),
2035 "AMF missing -qp_i: {args:?}");
2036 assert!(!has_arg(&args, "-crf"),
2037 "AMF must not use -crf: {args:?}");
2038 }
2039
2040 #[test]
2042 fn videotoolbox_qp_always_rejected(
2043 crf in 0i32..63i32,
2044 h264_h265 in prop_oneof![Just(Codec::VideoToolboxH264), Just(Codec::VideoToolboxH265)],
2045 ) {
2046 let job = EncodeJob {
2047 codec: h264_h265, crf, rate_control: RateControlMode::Qp,
2048 preset: String::new(), resolution: None, extra_args: vec![],
2049 ..sample_job(RateControlMode::Qp)
2050 };
2051 assert!(build_encode_args(&job, EncodePass::Single).is_err(),
2052 "VideoToolbox QP should be rejected");
2053 }
2054
2055 #[test]
2058 fn vbr_single_pass_errors_for_sw_codecs(
2059 target_br in 100.0f64..100000.0f64,
2060 ) {
2061 for codec in &[Codec::X264, Codec::X265, Codec::SvtAv1, Codec::Vp9] {
2062 let job = EncodeJob {
2063 codec: *codec, rate_control: RateControlMode::Vbr,
2064 target_bitrate: target_br,
2065 ..sample_job(RateControlMode::Vbr)
2066 };
2067 assert!(build_encode_args(&job, EncodePass::Single).is_err(),
2068 "{codec:?} VBR single-pass should error");
2069 }
2070 }
2071
2072 #[test]
2074 fn hw_vbr_single_pass_is_valid(
2075 target_br in 100.0f64..100000.0f64,
2076 codec in prop_oneof![
2077 Just(Codec::NvencH264), Just(Codec::QsvH264),
2078 Just(Codec::VideoToolboxH264), Just(Codec::VaapiH264), Just(Codec::AmfH264),
2079 ],
2080 ) {
2081 let job = EncodeJob {
2082 codec, rate_control: RateControlMode::Vbr,
2083 target_bitrate: target_br,
2084 resolution: None, preset: String::new(), extra_args: vec![],
2085 ..sample_job(RateControlMode::Vbr)
2086 };
2087 let args = build_encode_args(&job, EncodePass::Single).unwrap();
2088 assert!(has_pair(&args, "-b:v", &format!("{target_br:.0}k")),
2089 "HW VBR single-pass missing -b:v: {args:?}");
2090 }
2091
2092 #[test]
2094 fn single_pass_output_is_file(job in arb_encode_job()) {
2095 if let Ok(args) = build_encode_args(&job, EncodePass::Single) {
2096 let last = args.last().unwrap();
2097 assert!(!last.starts_with('-'),
2098 "last arg should not be a flag: {last}");
2099 assert!(!last.is_empty(),
2100 "last arg should not be empty");
2101 }
2102 }
2103 }
2104
2105 fn arb_encode_job_sw_capped() -> impl Strategy<Value = EncodeJob> {
2106 (any::<i32>(), any::<f64>(), any::<f64>(), any::<String>()).prop_map(
2107 |(crf, max_br, bufsize, preset)| {
2108 let crf = crf.abs().min(63);
2109 let max_br = max_br.abs().clamp(100.0, 100000.0);
2110 EncodeJob {
2111 codec: Codec::X264,
2112 crf,
2113 rate_control: RateControlMode::CappedCrf,
2114 max_bitrate: max_br,
2115 bufsize: bufsize.abs().min(200000.0),
2116 preset,
2117 resolution: None,
2118 extra_args: vec![],
2119 ..sample_job(RateControlMode::CappedCrf)
2120 }
2121 },
2122 )
2123 }
2124
2125 fn arb_encode_job_sw_vbr() -> impl Strategy<Value = EncodeJob> {
2126 (any::<i32>(), any::<f64>(), any::<String>()).prop_map(|(crf, target_br, preset)| {
2127 let crf = crf.abs().min(63);
2128 let target_br = target_br.abs().clamp(100.0, 100000.0);
2129 EncodeJob {
2130 codec: Codec::X264,
2131 crf,
2132 rate_control: RateControlMode::Vbr,
2133 target_bitrate: target_br,
2134 preset,
2135 resolution: None,
2136 extra_args: vec![],
2137 ..sample_job(RateControlMode::Vbr)
2138 }
2139 })
2140 }
2141 }
2142}