av1an_core/
settings.rs

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), /* tile (cols, rows) count; log2 will be applied later
96                                          * for specific encoders */
97    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>), // Width and Height
102    pub chroma_noise:        bool,
103    pub zones:               Option<PathBuf>,
104    pub cache_mode:          CacheSource,
105
106    // FFmpeg params
107    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            // Frame count must match
159            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                // merge video_params with defaults, overriding defaults
286                // TODO: consider using hashmap to store program arguments instead of string
287                // vector
288                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(&param)
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            // --rt must be used with 1-pass mode
335            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                    // These encoders require args to be passed using an equal sign,
353                    // e.g. `--cq-level=30`
354                    param.split('=').next()
355                } else {
356                    // The other encoders use a space, so we don't need to do extra splitting,
357                    // e.g. `--crf 30`
358                    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    /// Warns if rate control was not specified in encoder arguments
389    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    // Minimum threshold to consider a suggestion similar enough that it could be a
535    // typo
536    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}