pixelflow-filters 0.1.0

Official in-repository filters for PixelFlow.
//! Shared contracts and defaults for official PixelFlow filters.

use pixelflow_core::{
    ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterCompatibility,
    FilterDescriptor, FormatDescriptor, FormatFamily, PixelFlowError, Result, SampleType,
};

/// Publisher namespace used by all official in-repository filters.
pub const STDLIB_PUBLISHER: &str = "pixelflow";

/// Plugin namespace used by standard in-repository filters.
pub const STDLIB_PLUGIN: &str = "std";

pub(crate) const ALL_PLANAR_FAMILIES: &[FormatFamily] = &[
    FormatFamily::Gray,
    FormatFamily::Yuv,
    FormatFamily::PlanarRgb,
];
pub(crate) const ALL_SAMPLE_TYPES: &[SampleType] =
    &[SampleType::U8, SampleType::U16, SampleType::F32];

/// Dither behavior for conversion filters.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum DitherMode {
    /// Fixed Fruit dither, enabled by default for lossy conversions.
    #[default]
    Fruit,
    /// Disable dithering for deterministic or expert use cases.
    None,
}

/// Supported input format declaration for an official filter.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct SupportedFormats {
    families: &'static [FormatFamily],
    sample_types: &'static [SampleType],
}

impl SupportedFormats {
    /// Creates a supported-format declaration. Empty slices mean no restriction on that axis.
    #[must_use]
    pub const fn new(
        families: &'static [FormatFamily],
        sample_types: &'static [SampleType],
    ) -> Self {
        Self {
            families,
            sample_types,
        }
    }

    /// Returns true when `format` is accepted by this declaration.
    #[must_use]
    pub fn contains(self, format: &FormatDescriptor) -> bool {
        (self.families.is_empty() || self.families.contains(&format.family()))
            && (self.sample_types.is_empty() || self.sample_types.contains(&format.sample_type()))
    }
}

/// Shared upfront media contract for one official filter.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct OfficialFilterContract {
    name: &'static str,
    supported_formats: SupportedFormats,
    compatibility: FilterCompatibility,
}

impl OfficialFilterContract {
    /// Creates an official filter contract.
    #[must_use]
    pub const fn new(
        name: &'static str,
        supported_formats: SupportedFormats,
        compatibility: FilterCompatibility,
    ) -> Self {
        Self {
            name,
            supported_formats,
            compatibility,
        }
    }

    /// Returns stable script-facing filter name.
    #[must_use]
    pub const fn name(self) -> &'static str {
        self.name
    }

    /// Returns declared graph compatibility policy.
    #[must_use]
    pub const fn compatibility(self) -> FilterCompatibility {
        self.compatibility
    }

    /// Creates registry descriptor using official in-repository namespace conventions.
    #[must_use]
    pub fn descriptor(self) -> FilterDescriptor {
        FilterDescriptor::new(self.name, STDLIB_PUBLISHER, STDLIB_PLUGIN)
    }

    /// Validates media that must be known before constructing a render executor.
    pub fn validate_input_media(self, media: &ClipMedia) -> Result<&FormatDescriptor> {
        let ClipFormat::Fixed(format) = media.format() else {
            return Err(filter_error(
                "filter.variable_format",
                format!("filter '{}' requires fixed input format", self.name),
            ));
        };

        match media.resolution() {
            ClipResolution::Fixed { width, height } if *width > 0 && *height > 0 => {}
            ClipResolution::Fixed { .. } => {
                return Err(filter_error(
                    "filter.invalid_resolution",
                    format!("filter '{}' requires non-zero input resolution", self.name),
                ));
            }
            ClipResolution::Variable => {
                return Err(filter_error(
                    "filter.variable_resolution",
                    format!("filter '{}' requires fixed input resolution", self.name),
                ));
            }
        }

        if !self.supported_formats.contains(format) {
            return Err(filter_error(
                "filter.unsupported_format",
                format!(
                    "filter '{}' does not support input format '{}'",
                    self.name,
                    format.name()
                ),
            ));
        }

        Ok(format)
    }
}

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

#[cfg(test)]
mod tests {
    use pixelflow_core::{
        ClipFormat, ClipMedia, ClipResolution, ErrorCategory, ErrorCode, FilterCompatibility,
        FormatFamily, FrameCount, FrameRate, Rational, SampleType,
    };

    use crate::testkit::fixed_media;
    use crate::{
        BICUBIC_DEFAULT_B, BICUBIC_DEFAULT_C, COLORSPACE_GOLDEN_TOLERANCE, LANCZOS_DEFAULT_TAPS,
        RESIZE_CONTRACT, resize::RESIZE_GOLDEN_TOLERANCE,
    };

    use crate::EXACT_GOLDEN_TOLERANCE;

    use super::{DitherMode, OfficialFilterContract, SupportedFormats};

    #[test]
    fn official_contract_rejects_variable_format_before_render() {
        let media = ClipMedia::new(
            ClipFormat::Variable,
            ClipResolution::Fixed {
                width: 8,
                height: 6,
            },
            FrameCount::Finite(2),
            FrameRate::Cfr(Rational {
                numerator: 24,
                denominator: 1,
            }),
        );

        let error = RESIZE_CONTRACT
            .validate_input_media(&media)
            .expect_err("variable format should fail upfront");

        assert_eq!(error.category(), ErrorCategory::Format);
        assert_eq!(error.code(), ErrorCode::new("filter.variable_format"));
    }

    #[test]
    fn official_contract_rejects_unsupported_format_before_render() {
        let media = fixed_media("rgbpf32", 8, 6);
        let contract = OfficialFilterContract::new(
            "integer_yuv_only",
            SupportedFormats::new(&[FormatFamily::Yuv], &[SampleType::U8]),
            FilterCompatibility::Preserve,
        );

        let error = contract
            .validate_input_media(&media)
            .expect_err("contract should reject unsupported input format");

        assert_eq!(error.category(), ErrorCategory::Format);
        assert_eq!(error.code(), ErrorCode::new("filter.unsupported_format"));
        assert!(error.message().contains("integer_yuv_only"));
        assert!(error.message().contains("rgbpf32"));
    }

    #[test]
    fn official_contract_builds_descriptor_with_standard_namespace() {
        let descriptor = RESIZE_CONTRACT.descriptor();

        assert_eq!(descriptor.name(), "resize");
        assert_eq!(descriptor.publisher(), "pixelflow");
        assert_eq!(descriptor.plugin(), "std");
    }

    #[test]
    fn resize_and_dither_defaults_are_fixed_for_later_filter_tasks() {
        assert_eq!(BICUBIC_DEFAULT_B, 1.0 / 3.0);
        assert_eq!(BICUBIC_DEFAULT_C, 1.0 / 3.0);
        assert_eq!(LANCZOS_DEFAULT_TAPS, 3);
        assert_eq!(DitherMode::default(), DitherMode::Fruit);
    }

    #[test]
    fn golden_tolerances_are_explicit() {
        assert_eq!(EXACT_GOLDEN_TOLERANCE.u8_abs, 0);
        assert_eq!(EXACT_GOLDEN_TOLERANCE.u16_abs, 0);
        assert_eq!(EXACT_GOLDEN_TOLERANCE.f32_abs, 0.0);

        assert_eq!(RESIZE_GOLDEN_TOLERANCE.u8_abs, 1);
        assert_eq!(RESIZE_GOLDEN_TOLERANCE.u16_abs, 4);
        assert_eq!(RESIZE_GOLDEN_TOLERANCE.f32_abs, 0.00001);

        assert_eq!(COLORSPACE_GOLDEN_TOLERANCE.u8_abs, 1);
        assert_eq!(COLORSPACE_GOLDEN_TOLERANCE.u16_abs, 8);
        assert_eq!(COLORSPACE_GOLDEN_TOLERANCE.f32_abs, 0.0002);
    }
}