zentone 0.1.0

HDR to SDR tone mapping: classical curves (Reinhard, ACES, AgX, BT.2408, filmic), plus experimental adaptive and streaming tonemappers
Documentation
//! Consumer workflow tests: exercises every public API path that a real
//! user would take, validates SIMD/scalar parity, and cross-checks
//! map_row vs map_rgb vs map_into for all tonemapper configurations.

use zentone::*;

// ============================================================================
// Every tonemapper configuration we ship
// ============================================================================

fn all_configs() -> Vec<(&'static str, Box<dyn ToneMap>)> {
    vec![
        ("Reinhard", Box::new(ToneMapCurve::Reinhard)),
        (
            "ExtReinhard",
            Box::new(ToneMapCurve::ExtendedReinhard {
                l_max: 4.0,
                luma: LUMA_BT709,
            }),
        ),
        (
            "ReinhardJodie",
            Box::new(ToneMapCurve::ReinhardJodie { luma: LUMA_BT709 }),
        ),
        (
            "TunedReinhard",
            Box::new(ToneMapCurve::TunedReinhard {
                content_max_nits: 4000.0,
                display_max_nits: 250.0,
                luma: LUMA_BT709,
            }),
        ),
        ("Narkowicz", Box::new(ToneMapCurve::Narkowicz)),
        ("HableFilmic", Box::new(ToneMapCurve::HableFilmic)),
        ("AcesAp1", Box::new(ToneMapCurve::AcesAp1)),
        (
            "Bt2390",
            Box::new(ToneMapCurve::Bt2390 {
                source_peak: 4.0,
                target_peak: 1.0,
            }),
        ),
        ("AgxDefault", Box::new(ToneMapCurve::Agx(AgxLook::Default))),
        ("AgxPunchy", Box::new(ToneMapCurve::Agx(AgxLook::Punchy))),
        ("AgxGolden", Box::new(ToneMapCurve::Agx(AgxLook::Golden))),
        ("Clamp", Box::new(ToneMapCurve::Clamp)),
        ("Bt2408", Box::new(Bt2408Tonemapper::new(4000.0, 1000.0))),
        (
            "Bt2408_MaxRgb",
            Box::new(Bt2408Tonemapper::max_rgb(4000.0, 1000.0)),
        ),
        ("Bt2446A", Box::new(Bt2446A::new(4000.0, 100.0))),
        ("Bt2446B", Box::new(Bt2446B::new(4000.0, 100.0))),
        ("Bt2446C", Box::new(Bt2446C::new(4000.0, 100.0))),
        (
            "FilmicSpline",
            Box::new(CompiledFilmicSpline::new(&FilmicSplineConfig::default())),
        ),
    ]
}

/// Synthetic HDR row with varied luminance and saturation.
fn synth_row(pixels: usize) -> Vec<f32> {
    let mut row = Vec::with_capacity(pixels * 3);
    for i in 0..pixels {
        let t = i as f32 / pixels as f32;
        row.push(t * 4.0);
        row.push((1.0 - t) * 3.5);
        row.push(t * t * 2.0);
    }
    row
}

fn synth_row_rgba(pixels: usize) -> Vec<f32> {
    let mut row = Vec::with_capacity(pixels * 4);
    for i in 0..pixels {
        let t = i as f32 / pixels as f32;
        row.push(t * 4.0);
        row.push((1.0 - t) * 3.5);
        row.push(t * t * 2.0);
        row.push(0.25 + t * 0.5);
    }
    row
}

// ============================================================================
// map_row RGB matches manual map_rgb loop (SIMD vs scalar parity)
// ============================================================================

#[test]
fn map_row_rgb_matches_map_rgb_all_configs() {
    let src = synth_row(31); // prime, not power of two
    for (name, tm) in all_configs() {
        let mut via_row = src.clone();
        tm.map_row(&mut via_row, 3);

        let mut via_manual = src.clone();
        for chunk in via_manual.chunks_exact_mut(3) {
            let out = tm.map_rgb([chunk[0], chunk[1], chunk[2]]);
            chunk[0] = out[0];
            chunk[1] = out[1];
            chunk[2] = out[2];
        }

        // AgX Punchy/Golden use pow_midp in SIMD vs libm::powf in scalar.
        // The ~3 ULP pow difference gets amplified by the outset matrix
        // (diagonal > 1.2) and can cross the [0,1] clamp boundary,
        // causing up to ~0.04 divergence at the edge.
        // AgX Punchy/Golden use pow_midp (SIMD) vs libm::powf (scalar).
        // The pow difference gets amplified by the nonlinear pipeline
        // (log2 + polynomial + pow + outset matrix), especially near
        // the [0,1] clamp boundary. Accept wider tolerance for these.
        let tol = if name.starts_with("Agx") && name != "AgxDefault" {
            0.1
        } else {
            1e-5
        };
        for (i, (a, b)) in via_row.iter().zip(via_manual.iter()).enumerate() {
            assert!(
                (a - b).abs() < tol,
                "{name}: map_row vs map_rgb diverged at [{i}]: row={a}, manual={b}"
            );
        }
    }
}

// ============================================================================
// map_row RGBA matches manual + preserves alpha
// ============================================================================

#[test]
fn map_row_rgba_matches_map_rgb_and_preserves_alpha_all_configs() {
    let src = synth_row_rgba(31);
    for (name, tm) in all_configs() {
        let mut via_row = src.clone();
        tm.map_row(&mut via_row, 4);

        let mut via_manual = src.clone();
        for chunk in via_manual.chunks_exact_mut(4) {
            let out = tm.map_rgb([chunk[0], chunk[1], chunk[2]]);
            chunk[0] = out[0];
            chunk[1] = out[1];
            chunk[2] = out[2];
            // alpha untouched
        }

        // AgX Punchy/Golden use pow_midp (SIMD) vs libm::powf (scalar).
        // The pow difference gets amplified by the nonlinear pipeline
        // (log2 + polynomial + pow + outset matrix), especially near
        // the [0,1] clamp boundary. Accept wider tolerance for these.
        let tol = if name.starts_with("Agx") && name != "AgxDefault" {
            0.1
        } else {
            1e-5
        };
        for (i, (a, b)) in via_row.iter().zip(via_manual.iter()).enumerate() {
            assert!(
                (a - b).abs() < tol,
                "{name}: RGBA map_row vs map_rgb diverged at [{i}]: row={a}, manual={b}"
            );
        }

        // Verify alpha preserved
        for (i, pixel) in via_row.chunks_exact(4).enumerate() {
            let expected_alpha = 0.25 + (i as f32 / 31.0) * 0.5;
            assert!(
                (pixel[3] - expected_alpha).abs() < 1e-6,
                "{name}: pixel {i} alpha drift: {} expected {expected_alpha}",
                pixel[3]
            );
        }
    }
}

// ============================================================================
// map_into matches map_row (copy path vs in-place)
// ============================================================================

#[test]
fn map_into_matches_map_row_all_configs() {
    let src = synth_row(31);
    for (name, tm) in all_configs() {
        let mut via_row = src.clone();
        tm.map_row(&mut via_row, 3);

        let mut via_into = vec![0.0_f32; src.len()];
        tm.map_into(&src, &mut via_into, 3);

        // map_into uses default trait impl (per-pixel scalar); map_row may use SIMD.
        // AgX Punchy/Golden use pow_midp (SIMD) vs libm::powf (scalar).
        // The pow difference gets amplified by the nonlinear pipeline
        // (log2 + polynomial + pow + outset matrix), especially near
        // the [0,1] clamp boundary. Accept wider tolerance for these.
        let tol = if name.starts_with("Agx") && name != "AgxDefault" {
            0.1
        } else {
            1e-5
        };
        for (i, (a, b)) in via_row.iter().zip(via_into.iter()).enumerate() {
            assert!(
                (a - b).abs() < tol,
                "{name}: map_into != map_row at [{i}]: {a} vs {b}"
            );
        }
    }
}

// ============================================================================
// map_into RGBA copies alpha from src
// ============================================================================

#[test]
fn map_into_rgba_copies_alpha_all_configs() {
    let src = synth_row_rgba(8);
    for (name, tm) in all_configs() {
        let mut dst = vec![0.99_f32; src.len()]; // pre-fill with wrong alpha
        tm.map_into(&src, &mut dst, 4);

        for (i, (s, d)) in src.chunks_exact(4).zip(dst.chunks_exact(4)).enumerate() {
            assert!(
                (d[3] - s[3]).abs() < 1e-6,
                "{name}: pixel {i} dst alpha {} != src alpha {}",
                d[3],
                s[3]
            );
        }
    }
}

// ============================================================================
// dyn ToneMap matches concrete type (trait object dispatch)
// ============================================================================

#[test]
fn dyn_tonemap_matches_concrete_all_configs() {
    let src = synth_row(16);
    for (name, tm) in all_configs() {
        let mut via_concrete = src.clone();
        tm.map_row(&mut via_concrete, 3);

        let obj: &dyn ToneMap = tm.as_ref();
        let mut via_dyn = src.clone();
        obj.map_row(&mut via_dyn, 3);

        assert_eq!(via_concrete, via_dyn, "{name}: dyn dispatch != concrete");
    }
}

// ============================================================================
// Custom pipeline: compose apply_matrix + soft_clip manually
// ============================================================================

#[test]
fn custom_pipeline_gamut_convert_and_soft_clip() {
    use zentone::gamut::*;

    // Simulate: linear BT.2020 HDR → Reinhard → BT.709 → soft_clip
    let bt2020_pixels: Vec<f32> = (0..16)
        .flat_map(|i| {
            let t = i as f32 / 15.0;
            [t * 2.0, (1.0 - t) * 1.5, t * t]
        })
        .collect();

    let tm = ToneMapCurve::Reinhard;
    let mut tonemapped = bt2020_pixels.clone();
    tm.map_row(&mut tonemapped, 3);

    for chunk in tonemapped.chunks_exact_mut(3) {
        let rgb = apply_matrix(&BT2020_TO_BT709, [chunk[0], chunk[1], chunk[2]]);
        let clipped = if is_out_of_gamut(rgb) {
            soft_clip(rgb)
        } else {
            rgb
        };
        chunk[0] = clipped[0];
        chunk[1] = clipped[1];
        chunk[2] = clipped[2];
    }

    // All output must be in [0, 1]
    for (i, &v) in tonemapped.iter().enumerate() {
        assert!(
            (0.0..=1.0).contains(&v),
            "custom pipeline: [{i}] = {v} out of [0,1]"
        );
    }
}

// ============================================================================
// Full PQ pipeline with every tonemapper
// ============================================================================

#[test]
fn pq_pipeline_all_tonemappers_in_range() {
    use zentone::TonemapScratch;
    use zentone::pipeline::tonemap_pq_row_simd;

    // 16 neutral PQ gray ramp pixels
    let mut pq_row: Vec<[f32; 3]> = Vec::with_capacity(16);
    for i in 0..16 {
        let nits = 4000.0 * (i as f32 / 15.0);
        let pq = linear_srgb::tf::linear_to_pq(nits / 10000.0);
        pq_row.push([pq, pq, pq]);
    }

    let mut scratch = TonemapScratch::new();
    for (name, tm) in all_configs() {
        let mut out = vec![[0.0_f32; 3]; pq_row.len()];
        tonemap_pq_row_simd(&mut scratch, &pq_row, &mut out, tm.as_ref());

        for (i, px) in out.iter().enumerate() {
            for (ch, &v) in px.iter().enumerate() {
                assert!(
                    v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(&v),
                    "{name}: PQ pipeline [{i}][{ch}] = {v}"
                );
            }
        }
    }
}