pixelflow-filters 0.1.0

Official in-repository filters for PixelFlow.
use std::path::{Path, PathBuf};

use pixelflow_core::{
    ErrorCategory, ErrorCode, FormatDescriptor, PixelFlowError, Rational, Result,
    SourceOptionValue, SourceRequest, resolve_format_alias,
};

use super::cache::{cache_file_name, default_cache_path};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum VfrMode {
    ImplicitNormalize,
    Normalize,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct Ffms2SourceOptions {
    source_path: PathBuf,
    cache_path: PathBuf,
    frame_rate_override: Option<Rational>,
    vfr_mode: VfrMode,
    output_format: FormatDescriptor,
    track: Option<usize>,
    threads: usize,
}

impl Ffms2SourceOptions {
    pub(crate) fn from_request(request: &SourceRequest, script_dir: Option<&Path>) -> Result<Self> {
        let source_path = resolve_path(script_dir, request.path());
        let mut cache_path = script_dir.map_or_else(
            || PathBuf::from(default_cache_name(request.path())),
            |dir| default_cache_path(dir, request.path()),
        );
        let mut frame_rate_override = None;
        let mut vfr_mode = VfrMode::ImplicitNormalize;
        let mut output_format = resolve_format_alias("yuv420p8")?;
        let mut track = None;
        let mut threads = 1usize;

        for (name, value) in request.options() {
            match name.as_str() {
                "cache" => {
                    let path = option_string(name, value)?;
                    cache_path = resolve_path(script_dir, &path);
                }
                "fps" => {
                    let rate = option_rational(name, value)?;
                    if rate.numerator <= 0 || rate.denominator <= 0 {
                        return Err(invalid_option(
                            "source.unknown_frame_rate",
                            "frame rate override must have positive numerator and denominator",
                        ));
                    }
                    frame_rate_override = Some(rate);
                }
                "vfr" => {
                    vfr_mode = match option_string(name, value)?.as_str() {
                        "normalize" => VfrMode::Normalize,
                        "passthrough" => {
                            return Err(invalid_option(
                                "source.vfr_passthrough_unsupported",
                                "vfr passthrough is reserved but unsupported in phase 1",
                            ));
                        }
                        other => {
                            return Err(invalid_option(
                                "source.invalid_option",
                                format!("unsupported vfr mode '{other}'"),
                            ));
                        }
                    };
                }
                "format" => {
                    output_format = resolve_format_alias(&option_string(name, value)?)?;
                }
                "track" => {
                    track = Some(option_usize(name, value)?);
                }
                "threads" => {
                    let value = option_usize(name, value)?;
                    threads = value.max(1);
                }
                other => {
                    return Err(invalid_option(
                        "source.invalid_option",
                        format!("unknown source option '{other}'"),
                    ));
                }
            }
        }

        Ok(Self {
            source_path,
            cache_path,
            frame_rate_override,
            vfr_mode,
            output_format,
            track,
            threads,
        })
    }

    pub(crate) fn source_path(&self) -> &Path {
        &self.source_path
    }

    pub(crate) fn cache_path(&self) -> &Path {
        &self.cache_path
    }

    pub(crate) const fn frame_rate_override(&self) -> Option<Rational> {
        self.frame_rate_override
    }

    pub(crate) const fn vfr_mode(&self) -> VfrMode {
        self.vfr_mode
    }

    pub(crate) const fn output_format(&self) -> &FormatDescriptor {
        &self.output_format
    }

    pub(crate) const fn track(&self) -> Option<usize> {
        self.track
    }

    pub(crate) const fn threads(&self) -> usize {
        self.threads
    }
}

fn resolve_path(base_dir: Option<&Path>, raw: &str) -> PathBuf {
    let path = Path::new(raw);
    if path.is_absolute() {
        path.to_path_buf()
    } else if let Some(base_dir) = base_dir {
        base_dir.join(path)
    } else {
        path.to_path_buf()
    }
}

fn default_cache_name(source_path: &str) -> String {
    cache_file_name(source_path)
}

fn option_string(name: &str, value: &SourceOptionValue) -> Result<String> {
    match value {
        SourceOptionValue::String(value) => Ok(value.clone()),
        _ => Err(invalid_option(
            "source.invalid_option",
            format!("source option '{name}' must be string"),
        )),
    }
}

fn option_rational(name: &str, value: &SourceOptionValue) -> Result<Rational> {
    match value {
        SourceOptionValue::Rational(value) => Ok(*value),
        _ => Err(invalid_option(
            "source.invalid_option",
            format!("source option '{name}' must be rational"),
        )),
    }
}

fn option_usize(name: &str, value: &SourceOptionValue) -> Result<usize> {
    match value {
        SourceOptionValue::Int(value) => usize::try_from(*value).map_err(|_| {
            invalid_option(
                "source.invalid_option",
                format!("source option '{name}' must be non-negative"),
            )
        }),
        _ => Err(invalid_option(
            "source.invalid_option",
            format!("source option '{name}' must be integer"),
        )),
    }
}

fn invalid_option(code: &'static str, message: impl Into<String>) -> PixelFlowError {
    PixelFlowError::new(ErrorCategory::Source, ErrorCode::new(code), message)
}

#[cfg(test)]
mod tests {
    use tempfile::tempdir;

    use pixelflow_core::{ErrorCategory, ErrorCode, Rational, SourceOptionValue, SourceRequest};

    use super::{Ffms2SourceOptions, VfrMode};

    #[test]
    fn options_resolve_relative_source_and_default_cache_against_script_dir() {
        let temp = tempdir().expect("tempdir");
        let request = SourceRequest::new("media/input clip.mkv");

        let options =
            Ffms2SourceOptions::from_request(&request, Some(temp.path())).expect("options parse");

        assert_eq!(
            options.source_path(),
            temp.path().join("media/input clip.mkv")
        );
        assert!(options.cache_path().starts_with(temp.path()));
        assert!(
            options
                .cache_path()
                .to_string_lossy()
                .ends_with(".ffms2.pfidx")
        );
        assert_eq!(options.vfr_mode(), VfrMode::ImplicitNormalize);
    }

    #[test]
    fn options_accept_explicit_cache_fps_vfr_format_track_and_threads() {
        let request = SourceRequest::new("input.mkv")
            .with_option("cache", SourceOptionValue::String("custom.idx".to_owned()))
            .with_option(
                "fps",
                SourceOptionValue::Rational(Rational {
                    numerator: 24_000,
                    denominator: 1_001,
                }),
            )
            .with_option("vfr", SourceOptionValue::String("normalize".to_owned()))
            .with_option("format", SourceOptionValue::String("yuv420p10".to_owned()))
            .with_option("track", SourceOptionValue::Int(2))
            .with_option("threads", SourceOptionValue::Int(4));

        let options = Ffms2SourceOptions::from_request(&request, None).expect("options parse");

        assert_eq!(
            options.frame_rate_override(),
            Some(Rational {
                numerator: 24_000,
                denominator: 1_001,
            })
        );
        assert_eq!(options.vfr_mode(), VfrMode::Normalize);
        assert_eq!(options.output_format().name(), "yuv420p10");
        assert_eq!(options.track(), Some(2));
        assert_eq!(options.threads(), 4);
    }

    #[test]
    fn options_reject_vfr_passthrough() {
        let request = SourceRequest::new("input.mkv")
            .with_option("vfr", SourceOptionValue::String("passthrough".to_owned()));

        let error =
            Ffms2SourceOptions::from_request(&request, None).expect_err("passthrough unsupported");

        assert_eq!(error.category(), ErrorCategory::Source);
        assert_eq!(
            error.code(),
            ErrorCode::new("source.vfr_passthrough_unsupported")
        );
    }
}