photograph 0.4.1

Native desktop photo browser and non-destructive editor for RAW images and color grading
use image::DynamicImage;

use crate::state::EditState;

/// Applies the graduated exposure filter, if configured.
pub fn apply(img: DynamicImage, state: &EditState) -> DynamicImage {
    let Some(grad) = state.graduated_filter.as_ref() else {
        return img;
    };
    if grad.exposure.abs() < 0.001 {
        return img;
    }

    let top = grad.top.clamp(0.0, 1.0);
    let bottom = grad.bottom.clamp(0.0, 1.0);
    if bottom <= top + 0.0001 {
        return img;
    }

    let exposure = grad.exposure.clamp(-5.0, 5.0);
    let mut rgba = img.to_rgba8();
    let h = rgba.height().max(1);
    let h_denom = (h - 1).max(1) as f32;

    for (y, row) in rgba.enumerate_rows_mut() {
        let y_norm = y as f32 / h_denom;
        let weight = if y_norm <= top {
            1.0
        } else if y_norm >= bottom {
            0.0
        } else {
            (bottom - y_norm) / (bottom - top)
        };
        if weight <= 0.0 {
            continue;
        }
        let gain = 2.0_f32.powf(exposure * weight);
        for (_, _, px) in row {
            for c in 0..3 {
                let v = px[c] as f32 / 255.0;
                px[c] = (v * gain).clamp(0.0, 1.0).mul_add(255.0, 0.0).round() as u8;
            }
        }
    }

    DynamicImage::ImageRgba8(rgba)
}

#[cfg(test)]
mod tests {
    use image::{DynamicImage, ImageBuffer, Rgba};

    use crate::state::{EditState, GradFilter};

    use super::apply;

    #[test]
    fn negative_exposure_darkens_top_more_than_bottom() {
        let img = DynamicImage::ImageRgba8(ImageBuffer::from_fn(1, 2, |_x, _y| {
            Rgba([200, 200, 200, 255])
        }));
        let mut state = EditState::default();
        state.graduated_filter = Some(GradFilter {
            top: 0.0,
            bottom: 1.0,
            exposure: -1.0,
        });

        let out = apply(img, &state).to_rgba8();
        assert!(out.get_pixel(0, 0)[0] < out.get_pixel(0, 1)[0]);
    }
}