pixelflow-filters 0.1.0

Official in-repository filters for PixelFlow.
use pixelflow_core::{
    ClipMedia, ErrorCategory, ErrorCode, FilterOptionValue, FilterOptions, FilterPlanRequest,
    PixelFlowError, Result,
};

use crate::DitherMode;

pub(crate) fn single_input_media<'a>(
    request: FilterPlanRequest<'a>,
    filter_name: &str,
) -> Result<&'a ClipMedia> {
    single_input_slice(request.input_media(), filter_name)
}

pub(crate) fn single_input_slice<'a>(
    input_media: &'a [ClipMedia],
    filter_name: &str,
) -> Result<&'a ClipMedia> {
    match input_media {
        [input] => Ok(input),
        inputs => Err(PixelFlowError::new(
            ErrorCategory::Graph,
            ErrorCode::new("graph.invalid_filter_inputs"),
            format!(
                "filter '{filter_name}' requires exactly one input clip, got {}",
                inputs.len()
            ),
        )),
    }
}

pub(crate) fn optional_string<'a>(
    options: &'a FilterOptions,
    filter_name: &str,
    name: &str,
    code: &'static str,
) -> Result<Option<&'a str>> {
    match options.get(name) {
        None | Some(FilterOptionValue::None) => Ok(None),
        Some(FilterOptionValue::String(value)) => Ok(Some(value.as_str())),
        Some(_) => Err(filter_option_error(
            code,
            format!("filter '{filter_name}' option '{name}' must be string"),
        )),
    }
}

pub(crate) fn required_string<'a>(
    options: &'a FilterOptions,
    filter_name: &str,
    name: &str,
    code: &'static str,
) -> Result<&'a str> {
    optional_string(options, filter_name, name, code)?.ok_or_else(|| {
        filter_option_error(
            code,
            format!("filter '{filter_name}' requires option '{name}'"),
        )
    })
}

pub(crate) fn optional_i64(
    options: &FilterOptions,
    filter_name: &str,
    name: &str,
    code: &'static str,
) -> Result<Option<i64>> {
    match options.get(name) {
        None | Some(FilterOptionValue::None) => Ok(None),
        Some(FilterOptionValue::Int(value)) => Ok(Some(*value)),
        Some(_) => Err(filter_option_error(
            code,
            format!("filter '{filter_name}' option '{name}' must be integer"),
        )),
    }
}

pub(crate) fn required_i64(
    options: &FilterOptions,
    filter_name: &str,
    name: &str,
    code: &'static str,
) -> Result<i64> {
    optional_i64(options, filter_name, name, code)?.ok_or_else(|| {
        filter_option_error(
            code,
            format!("filter '{filter_name}' requires option '{name}'"),
        )
    })
}

pub(crate) fn optional_f64(
    options: &FilterOptions,
    filter_name: &str,
    name: &str,
    code: &'static str,
) -> Result<Option<f64>> {
    match options.get(name) {
        None | Some(FilterOptionValue::None) => Ok(None),
        Some(FilterOptionValue::Int(value)) => Ok(Some(*value as f64)),
        Some(FilterOptionValue::Float(value)) => Ok(Some(*value)),
        Some(_) => Err(filter_option_error(
            code,
            format!("filter '{filter_name}' option '{name}' must be number"),
        )),
    }
}

pub(crate) fn optional_dither(
    options: &FilterOptions,
    filter_name: &str,
    code: &'static str,
) -> Result<Option<DitherMode>> {
    let Some(dither) = optional_string(options, filter_name, "dither", code)? else {
        return Ok(None);
    };

    match dither.to_ascii_lowercase().as_str() {
        "fruit" => Ok(Some(DitherMode::Fruit)),
        "none" => Ok(Some(DitherMode::None)),
        _ => Err(filter_option_error(
            code,
            format!("filter '{filter_name}' option 'dither' must be 'fruit' or 'none'"),
        )),
    }
}

pub(crate) fn filter_option_error(
    code: &'static str,
    message: impl Into<String>,
) -> PixelFlowError {
    PixelFlowError::new(ErrorCategory::Format, ErrorCode::new(code), message)
}

#[cfg(test)]
mod tests {
    use pixelflow_core::{ErrorCategory, ErrorCode};

    #[test]
    fn single_input_slice_reuses_existing_input_diagnostic() {
        let inputs = [
            crate::testkit::fixed_media("gray8", 2, 2),
            crate::testkit::fixed_media("gray8", 2, 2),
        ];

        let error = super::single_input_slice(&inputs, "resize")
            .expect_err("two inputs should fail for single-input filters");

        assert_eq!(error.category(), ErrorCategory::Graph);
        assert_eq!(error.code(), ErrorCode::new("graph.invalid_filter_inputs"));
        assert!(error.message().contains("resize"));
        assert!(error.message().contains("2"));
    }
}