1use std::{
2 borrow::{Borrow, Cow},
3 cmp::Ordering,
4 collections::HashSet,
5 path::{absolute, Path, PathBuf},
6 process::{exit, Command},
7};
8
9use anyhow::{bail, ensure};
10use itertools::{chain, Itertools};
11use serde::{Deserialize, Serialize};
12use tracing::warn;
13
14use crate::{
15 concat::ConcatMethod,
16 encoder::Encoder,
17 ffmpeg::FFPixelFormat,
18 metrics::{vmaf::validate_libvmaf, xpsnr::validate_libxpsnr},
19 parse::valid_params,
20 target_quality::TargetQuality,
21 vapoursynth::{CacheSource, VSZipVersion, VapoursynthPlugins},
22 ChunkMethod,
23 ChunkOrdering,
24 Input,
25 ScenecutMethod,
26 SplitMethod,
27 TargetMetric,
28 Verbosity,
29};
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
32pub struct PixelFormat {
33 pub format: FFPixelFormat,
34 pub bit_depth: usize,
35}
36
37#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
38pub enum InputPixelFormat {
39 VapourSynth { bit_depth: usize },
40 FFmpeg { format: FFPixelFormat },
41}
42
43impl InputPixelFormat {
44 #[inline]
45 pub fn as_bit_depth(&self) -> anyhow::Result<usize> {
46 match self {
47 InputPixelFormat::VapourSynth {
48 bit_depth,
49 } => Ok(*bit_depth),
50 InputPixelFormat::FFmpeg {
51 ..
52 } => Err(anyhow::anyhow!("failed to get bit depth; wrong input type")),
53 }
54 }
55
56 #[inline]
57 pub fn as_pixel_format(&self) -> anyhow::Result<FFPixelFormat> {
58 match self {
59 InputPixelFormat::VapourSynth {
60 ..
61 } => Err(anyhow::anyhow!("failed to get bit depth; wrong input type")),
62 InputPixelFormat::FFmpeg {
63 format,
64 } => Ok(*format),
65 }
66 }
67}
68
69#[expect(clippy::struct_excessive_bools)]
70#[derive(Debug)]
71pub struct EncodeArgs {
72 pub input: Input,
73 pub proxy: Option<Input>,
74 pub temp: String,
75 pub output_file: String,
76
77 pub chunk_method: ChunkMethod,
78 pub chunk_order: ChunkOrdering,
79 pub scaler: String,
80 pub scenes: Option<PathBuf>,
81 pub split_method: SplitMethod,
82 pub sc_pix_format: Option<FFPixelFormat>,
83 pub sc_method: ScenecutMethod,
84 pub sc_only: bool,
85 pub sc_downscale_height: Option<usize>,
86 pub extra_splits_len: Option<usize>,
87 pub min_scene_len: usize,
88 pub force_keyframes: Vec<usize>,
89 pub ignore_frame_mismatch: bool,
90
91 pub max_tries: usize,
92
93 pub passes: u8,
94 pub video_params: Vec<String>,
95 pub tiles: (u32, u32), pub encoder: Encoder,
98 pub workers: usize,
99 pub set_thread_affinity: Option<usize>,
100 pub photon_noise: Option<u8>,
101 pub photon_noise_size: (Option<u32>, Option<u32>), pub chroma_noise: bool,
103 pub zones: Option<PathBuf>,
104 pub cache_mode: CacheSource,
105
106 pub ffmpeg_filter_args: Vec<String>,
108 pub audio_params: Vec<String>,
109 pub input_pix_format: InputPixelFormat,
110 pub output_pix_format: PixelFormat,
111
112 pub verbosity: Verbosity,
113 pub resume: bool,
114 pub keep: bool,
115 pub force: bool,
116 pub no_defaults: bool,
117 pub tile_auto: bool,
118
119 pub concat: ConcatMethod,
120 pub target_quality: TargetQuality,
121 pub vmaf: bool,
122 pub vmaf_path: Option<PathBuf>,
123 pub vmaf_res: String,
124 pub probe_res: Option<String>,
125 pub vmaf_threads: Option<usize>,
126 pub vmaf_filter: Option<String>,
127
128 pub vapoursynth_plugins: Option<VapoursynthPlugins>,
129}
130
131impl EncodeArgs {
132 #[inline]
133 pub fn validate(&mut self) -> anyhow::Result<()> {
134 if self.concat == ConcatMethod::Ivf
135 && !matches!(
136 self.encoder,
137 Encoder::rav1e | Encoder::aom | Encoder::svt_av1 | Encoder::vpx
138 )
139 {
140 bail!(".ivf only supports VP8, VP9, and AV1");
141 }
142
143 ensure!(self.max_tries > 0);
144
145 ensure!(
146 self.input.as_path().exists(),
147 "Input file {:?} does not exist!",
148 self.input
149 );
150
151 if let Some(proxy) = &self.proxy {
152 ensure!(
153 proxy.as_path().exists(),
154 "Proxy file {:?} does not exist!",
155 proxy
156 );
157
158 let input_frame_count = self.input.clip_info()?.num_frames;
160 let proxy_frame_count = proxy.clip_info()?.num_frames;
161
162 ensure!(
163 input_frame_count == proxy_frame_count,
164 "Input and Proxy do not have the same number of frames! ({input_frame_count} != \
165 {proxy_frame_count})",
166 );
167 }
168
169 if self.target_quality.target.is_some() && self.input.is_vapoursynth() {
170 let input_absolute_path = absolute(self.input.as_path())?;
171 if !input_absolute_path.starts_with(std::env::current_dir()?) {
172 warn!(
173 "Target Quality with VapourSynth script file input not in current working \
174 directory. It is recommended to run in the same directory."
175 );
176 }
177 }
178 if self.target_quality.target.is_some() {
179 match self.target_quality.metric {
180 TargetMetric::VMAF => validate_libvmaf()?,
181 TargetMetric::SSIMULACRA2 => self.validate_ssimulacra2()?,
182 TargetMetric::ButteraugliINF => self.validate_butteraugli_inf()?,
183 TargetMetric::Butteraugli3 => self.validate_butteraugli_3()?,
184 TargetMetric::XPSNR | TargetMetric::XPSNRWeighted => self
185 .validate_xpsnr(self.target_quality.metric, self.target_quality.probing_rate)?,
186 }
187 }
188
189 if which::which("ffmpeg").is_err() {
190 bail!("FFmpeg not found. Is it installed in system path?");
191 }
192
193 if self.concat == ConcatMethod::MKVMerge && which::which("mkvmerge").is_err() {
194 if self.sc_only {
195 warn!(
196 "mkvmerge not found, but `--concat mkvmerge` was specified. Make sure to \
197 install mkvmerge or specify a different concatenation method (e.g. `--concat \
198 ffmpeg`) before encoding."
199 );
200 } else {
201 bail!(
202 "mkvmerge not found, but `--concat mkvmerge` was specified. Is it installed \
203 in system path?"
204 );
205 }
206 }
207
208 if self.encoder == Encoder::x265 && self.concat != ConcatMethod::MKVMerge {
209 bail!(
210 "mkvmerge is required for concatenating x265, as x265 outputs raw HEVC bitstream \
211 files without the timestamps correctly set, which FFmpeg cannot concatenate \
212 properly into a mkv file. Specify mkvmerge as the concatenation method by \
213 setting `--concat mkvmerge`."
214 );
215 }
216
217 if self.encoder == Encoder::vpx && self.concat != ConcatMethod::MKVMerge {
218 warn!(
219 "mkvmerge is recommended for concatenating vpx, as vpx outputs with incorrect \
220 frame rates, which we can only resolve using mkvmerge. Specify mkvmerge as the \
221 concatenation method by setting `--concat mkvmerge`."
222 );
223 }
224
225 if self.chunk_method == ChunkMethod::LSMASH {
226 ensure!(
227 self.vapoursynth_plugins.is_some_and(|p| p.lsmash),
228 "LSMASH is not installed, but it was specified as the chunk method"
229 );
230 }
231 if self.chunk_method == ChunkMethod::FFMS2 {
232 ensure!(
233 self.vapoursynth_plugins.is_some_and(|p| p.ffms2),
234 "FFMS2 is not installed, but it was specified as the chunk method"
235 );
236 }
237 if self.chunk_method == ChunkMethod::DGDECNV && which::which("dgindexnv").is_err() {
238 ensure!(
239 self.vapoursynth_plugins.is_some_and(|p| p.dgdecnv),
240 "Either DGDecNV is not installed or DGIndexNV is not in system path, but it was \
241 specified as the chunk method"
242 );
243 }
244 if self.chunk_method == ChunkMethod::BESTSOURCE {
245 ensure!(
246 self.vapoursynth_plugins.is_some_and(|p| p.bestsource),
247 "BestSource is not installed, but it was specified as the chunk method"
248 );
249 }
250 if self.chunk_method == ChunkMethod::Select {
251 warn!("It is not recommended to use the \"select\" chunk method, as it is very slow");
252 }
253
254 if self.ignore_frame_mismatch {
255 warn!(
256 "The output video's frame count may differ, and target metric calculations may be \
257 incorrect"
258 );
259 }
260
261 if let Some(vmaf_path) = self.target_quality.model.as_ref() {
262 ensure!(vmaf_path.exists());
263 }
264
265 if self.target_quality.probes < 4 {
266 warn!("Target quality with fewer than 4 probes is experimental and not recommended");
267 }
268
269 let encoder_bin = self.encoder.bin();
270 if which::which(encoder_bin).is_err() {
271 bail!(
272 "Encoder {} not found. Is it installed in the system path?",
273 encoder_bin
274 );
275 }
276
277 if self.tile_auto {
278 self.tiles = self.input.calculate_tiles();
279 }
280
281 if !self.no_defaults {
282 if self.video_params.is_empty() {
283 self.video_params = self.encoder.get_default_arguments(self.tiles);
284 } else {
285 let default_video_params = self.encoder.get_default_arguments(self.tiles);
289 let mut skip = false;
290 let mut _default_params: Vec<String> = Vec::new();
291 for param in default_video_params {
292 if skip && !(param.starts_with("-") && param != "-1") {
293 skip = false;
294 continue;
295 }
296
297 skip = false;
298 if (param.starts_with("-") && param != "-1")
299 && self.video_params.contains(¶m)
300 {
301 skip = true;
302 continue;
303 }
304
305 _default_params.push(param);
306 }
307 self.video_params = chain!(_default_params, self.video_params.clone()).collect();
308 }
309 }
310
311 if let Some(strength) = self.photon_noise {
312 if strength > 64 {
313 bail!("Valid strength values for photon noise are 0-64");
314 }
315 if ![Encoder::aom, Encoder::rav1e, Encoder::svt_av1].contains(&self.encoder) {
316 bail!("Photon noise synth is only supported with aomenc, rav1e, and svt-av1");
317 }
318 }
319
320 if self.encoder == Encoder::aom
321 && self.concat != ConcatMethod::MKVMerge
322 && self.video_params.iter().any(|param| param == "--enable-keyframe-filtering=2")
323 {
324 bail!(
325 "keyframe filtering mode 2 currently only works when using mkvmerge as the concat \
326 method"
327 );
328 }
329
330 if matches!(self.encoder, Encoder::aom | Encoder::vpx)
331 && self.passes != 1
332 && self.video_params.iter().any(|param| param == "--rt")
333 {
334 self.passes = 1;
336 }
337
338 if !self.force {
339 self.validate_encoder_params()?;
340 self.check_rate_control();
341 }
342
343 Ok(())
344 }
345
346 fn validate_encoder_params(&self) -> anyhow::Result<()> {
347 let video_params: Vec<&str> = self
348 .video_params
349 .iter()
350 .filter_map(|param| {
351 if param.starts_with('-') && [Encoder::aom, Encoder::vpx].contains(&self.encoder) {
352 param.split('=').next()
355 } else {
356 None
359 }
360 })
361 .collect();
362
363 let help_text = {
364 let [cmd, arg] = self.encoder.help_command();
365 String::from_utf8_lossy(&Command::new(cmd).arg(arg).output()?.stdout).to_string()
366 };
367 let valid_params = valid_params(&help_text, self.encoder);
368 let invalid_params = invalid_params(&video_params, &valid_params);
369
370 for wrong_param in &invalid_params {
371 eprintln!(
372 "'{}' isn't a valid parameter for {}",
373 wrong_param, self.encoder,
374 );
375 if let Some(suggestion) = suggest_fix(wrong_param, &valid_params) {
376 eprintln!("\tDid you mean '{suggestion}'?");
377 }
378 }
379
380 if !invalid_params.is_empty() {
381 println!("\nTo continue anyway, run av1an with '--force'");
382 exit(1);
383 }
384
385 Ok(())
386 }
387
388 fn check_rate_control(&self) {
390 if self.encoder == Encoder::aom {
391 if !self.video_params.iter().any(|f| Self::check_aom_encoder_mode(f)) {
392 warn!("[WARN] --end-usage was not specified");
393 }
394
395 if !self.video_params.iter().any(|f| Self::check_aom_rate(f)) {
396 warn!("[WARN] --cq-level or --target-bitrate was not specified");
397 }
398 }
399 }
400
401 fn check_aom_encoder_mode(s: &str) -> bool {
402 const END_USAGE: &str = "--end-usage=";
403 if s.len() <= END_USAGE.len() || !s.starts_with(END_USAGE) {
404 return false;
405 }
406
407 s.as_bytes()[END_USAGE.len()..]
408 .iter()
409 .all(|&b| (b as char).is_ascii_alphabetic())
410 }
411
412 fn check_aom_rate(s: &str) -> bool {
413 const CQ_LEVEL: &str = "--cq-level=";
414 const TARGET_BITRATE: &str = "--target-bitrate=";
415
416 if s.len() <= CQ_LEVEL.len() || !(s.starts_with(TARGET_BITRATE) || s.starts_with(CQ_LEVEL))
417 {
418 return false;
419 }
420
421 if s.starts_with(CQ_LEVEL) {
422 s.as_bytes()[CQ_LEVEL.len()..].iter().all(|&b| (b as char).is_ascii_digit())
423 } else {
424 s.as_bytes()[TARGET_BITRATE.len()..]
425 .iter()
426 .all(|&b| (b as char).is_ascii_digit())
427 }
428 }
429
430 #[inline]
431 pub fn validate_ssimulacra2(&self) -> anyhow::Result<()> {
432 ensure!(
433 self.vapoursynth_plugins.is_some_and(|p| p.vship)
434 || self.vapoursynth_plugins.is_some_and(|p| p.vszip != VSZipVersion::None),
435 "SSIMULACRA2 metric requires either Vapoursynth-HIP or VapourSynth Zig Image Process \
436 to be installed"
437 );
438 self.ensure_chunk_method(
439 "Chunk method must be lsmash, ffms2, bestsource, or dgdecnv for SSIMULACRA2"
440 .to_string(),
441 )?;
442
443 Ok(())
444 }
445
446 #[inline]
447 pub fn validate_butteraugli_inf(&self) -> anyhow::Result<()> {
448 ensure!(
449 self.vapoursynth_plugins.is_some_and(|p| p.vship)
450 || self.vapoursynth_plugins.is_some_and(|p| p.julek),
451 "Butteraugli metric requires either Vapoursynth-HIP or vapoursynth-julek-plugin to be \
452 installed"
453 );
454 self.ensure_chunk_method(
455 "Chunk method must be lsmash, ffms2, bestsource, or dgdecnv for butteraugli"
456 .to_string(),
457 )?;
458
459 Ok(())
460 }
461
462 #[inline]
463 pub fn validate_butteraugli_3(&self) -> anyhow::Result<()> {
464 ensure!(
465 self.vapoursynth_plugins.is_some_and(|p| p.vship),
466 "Butteraugli 3 Norm metric requires Vapoursynth-HIP plugin to be installed"
467 );
468 self.ensure_chunk_method(
469 "Chunk method must be lsmash, ffms2, bestsource, or dgdecnv for butteraugli 3-Norm"
470 .to_string(),
471 )?;
472
473 Ok(())
474 }
475
476 #[inline]
477 pub fn validate_xpsnr(&self, metric: TargetMetric, probing_rate: usize) -> anyhow::Result<()> {
478 let metric_name = if metric == TargetMetric::XPSNRWeighted {
479 "Weighted XPSNR"
480 } else {
481 "XPSNR"
482 };
483 if probing_rate > 1 {
484 ensure!(
485 self.vapoursynth_plugins.is_some_and(|p| p.vszip == VSZipVersion::New),
486 format!(
487 "{metric_name} metric with probing rate greater than 1 requires \
488 VapourSynth-Zig Image Process R7 or newer to be installed"
489 )
490 );
491 self.ensure_chunk_method(format!(
492 "Chunk method must be lsmash, ffms2, bestsource, or dgdecnv for {metric_name} \
493 metric with probing rate greater than 1"
494 ))?;
495 } else {
496 validate_libxpsnr()?;
497 }
498
499 Ok(())
500 }
501
502 fn ensure_chunk_method(&self, error_message: String) -> anyhow::Result<()> {
503 ensure!(
504 matches!(
505 self.chunk_method,
506 ChunkMethod::LSMASH
507 | ChunkMethod::FFMS2
508 | ChunkMethod::BESTSOURCE
509 | ChunkMethod::DGDECNV
510 ),
511 error_message
512 );
513 Ok(())
514 }
515}
516
517#[must_use]
518pub(crate) fn invalid_params<'a>(
519 params: &'a [&'a str],
520 valid_options: &'a HashSet<Cow<'a, str>>,
521) -> Vec<&'a str> {
522 params
523 .iter()
524 .filter(|param| !valid_options.contains(Borrow::<str>::borrow(&**param)))
525 .copied()
526 .collect()
527}
528
529#[must_use]
530pub(crate) fn suggest_fix<'a>(
531 wrong_arg: &str,
532 arg_dictionary: &'a HashSet<Cow<'a, str>>,
533) -> Option<&'a str> {
534 const MIN_THRESHOLD: f64 = 0.75;
537
538 arg_dictionary
539 .iter()
540 .map(|arg| (arg, strsim::jaro_winkler(arg, wrong_arg)))
541 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(Ordering::Less))
542 .and_then(|(suggestion, score)| (score > MIN_THRESHOLD).then(|| suggestion.borrow()))
543}
544
545pub(crate) fn insert_noise_table_params(
546 encoder: Encoder,
547 video_params: &mut Vec<String>,
548 table: &Path,
549) -> anyhow::Result<()> {
550 match encoder {
551 Encoder::aom => {
552 video_params.retain(|param| !param.starts_with("--denoise-noise-level="));
553 video_params.push(format!("--film-grain-table={}", table.to_string_lossy()));
554 },
555 Encoder::svt_av1 => {
556 let film_grain_idx =
557 video_params.iter().find_position(|param| param.as_str() == "--film-grain");
558 if let Some((idx, _)) = film_grain_idx {
559 video_params.remove(idx + 1);
560 video_params.remove(idx);
561 }
562 video_params.push("--fgs-table".to_string());
563 video_params.push(table.to_string_lossy().to_string());
564 },
565 Encoder::rav1e => {
566 let photon_noise_idx =
567 video_params.iter().find_position(|param| param.as_str() == "--photon-noise");
568 if let Some((idx, _)) = photon_noise_idx {
569 video_params.remove(idx + 1);
570 video_params.remove(idx);
571 }
572 video_params.push("--photon-noise-table".to_string());
573 video_params.push(table.to_string_lossy().to_string());
574 },
575 _ => bail!("This encoder does not support grain synth through av1an"),
576 }
577
578 Ok(())
579}