agx-photo 0.1.0

An open-source photo editing library with a readable, portable preset format
Documentation
// Algorithm: Gamma-space per-pixel adjustment stack for contrast, tone curves, HSL, color grading, and LUT
// Canonical explanation: crates/agx/src/adjust/basic_tone.md, crates/agx/src/adjust/tone_curves.md, crates/agx/src/adjust/hsl.md, crates/agx/src/adjust/color_grading.md
// CPU equivalent: crates/agx/src/adjust/basic_tone.rs (apply_contrast/apply_highlights/apply_shadows/apply_whites/apply_blacks), crates/agx/src/adjust/tone_curves.rs (apply_tone_curves_pre), crates/agx/src/adjust/hsl.rs (apply_hsl), crates/agx/src/adjust/color_grading.rs (apply_color_grading_pre)
// Bindings: storage pixels/params/tone_curves plus 3D LUT texture+sampler
// Entry points: main

// Gamma-space per-pixel adjustments: contrast, highlights, shadows, whites, blacks,
// tone curves, HSL, color grading, LUT.

#import common::tone
#import common::color
#import common::math

struct Params {
    exposure: f32,
    temperature: f32,
    tint: f32,
    _pad0: f32,

    contrast: f32,
    highlights: f32,
    shadows: f32,
    whites: f32,

    blacks: f32,
    _pad1: array<f32, 3>,

    hue_shifts: array<f32, 8>,
    sat_shifts: array<f32, 8>,
    lum_shifts: array<f32, 8>,

    cg_shadow_tint: vec4f,
    cg_midtone_tint: vec4f,
    cg_highlight_tint: vec4f,
    cg_global_tint: vec4f,
    cg_balance_factor: f32,
    cg_balance_active: f32,
    cg_active: f32,
    _pad2: f32,

    vignette_amount: f32,
    vignette_shape: f32,
    hsl_active: f32,
    _pad3: f32,

    dehaze_amount: f32,
    _pad4: array<f32, 3>,

    grain_amount: f32,
    grain_size: f32,
    grain_type: f32,
    grain_seed: f32,

    tc_rgb_active: f32,
    tc_luma_active: f32,
    tc_red_active: f32,
    tc_green_active: f32,
    tc_blue_active: f32,
    lut_active: f32,
    _pad_tc: vec2f,

    width: f32,
    height: f32,
    _pad5: vec2f,

    detail_strength: f32,
    detail_threshold: f32,
    detail_masking: f32,
    kernel_size: f32,
}

// HSL channel centers (Red, Orange, Yellow, Green, Aqua, Blue, Purple, Magenta)
const CHANNEL_CENTERS = array<f32, 8>(0.0, 30.0, 60.0, 120.0, 180.0, 240.0, 270.0, 330.0);
const CHANNEL_HALF_WIDTHS = array<f32, 8>(30.0, 30.0, 30.0, 60.0, 60.0, 30.0, 30.0, 30.0);

@group(0) @binding(0) var<storage, read_write> pixels: array<f32>;
@group(0) @binding(1) var<storage, read> params: Params;
@group(0) @binding(2) var<storage, read> tone_curves: array<f32>;
@group(0) @binding(3) var lut_texture: texture_3d<f32>;
@group(0) @binding(4) var lut_sampler: sampler;

// --- Tone curve helpers ---

fn tone_curve_lookup(curve_offset: u32, value: f32) -> f32 {
    let idx = clamp(value * 255.0, 0.0, 255.0);
    let lo = u32(floor(idx));
    let hi = min(lo + 1u, 255u);
    let frac = idx - floor(idx);
    let v_lo = tone_curves[curve_offset + lo];
    let v_hi = tone_curves[curve_offset + hi];
    return v_lo + frac * (v_hi - v_lo);
}

fn apply_tone_curves(r_in: f32, g_in: f32, b_in: f32) -> vec3f {
    var r = r_in;
    var g = g_in;
    var b = b_in;

    // Step 1: RGB master curve (offset 0)
    if params.tc_rgb_active > 0.5 {
        r = tone_curve_lookup(0u, r);
        g = tone_curve_lookup(0u, g);
        b = tone_curve_lookup(0u, b);
    }

    // Step 2: Per-channel curves (red=512, green=768, blue=1024)
    if params.tc_red_active > 0.5 {
        r = tone_curve_lookup(512u, r);
    }
    if params.tc_green_active > 0.5 {
        g = tone_curve_lookup(768u, g);
    }
    if params.tc_blue_active > 0.5 {
        b = tone_curve_lookup(1024u, b);
    }

    // Step 3: Luminance curve (offset 256)
    if params.tc_luma_active > 0.5 {
        let l = common::math::luminance(r, g, b);
        let l_new = tone_curve_lookup(256u, l);
        if l > 1e-6 {
            let scale = l_new / l;
            r = clamp(r * scale, 0.0, 1.0);
            g = clamp(g * scale, 0.0, 1.0);
            b = clamp(b * scale, 0.0, 1.0);
        } else {
            r = l_new;
            g = l_new;
            b = l_new;
        }
    }

    return vec3f(r, g, b);
}

// --- HSL ---

fn apply_hsl_pixel(r: f32, g: f32, b: f32) -> vec3f {
    let hsl = common::color::rgb_to_hsl(vec3f(r, g, b));
    let pixel_hue = hsl.x;
    let pixel_sat = hsl.y;

    // Gray/near-gray pixels: hue is undefined, skip HSL adjustments
    if pixel_sat < 1e-4 {
        return vec3f(r, g, b);
    }

    var total_hue_shift = 0.0;
    var total_sat_shift = 0.0;
    var total_lum_shift = 0.0;

    for (var i = 0u; i < 8u; i = i + 1u) {
        let dist = common::color::hue_distance(pixel_hue, CHANNEL_CENTERS[i]);
        // Scale weight by pixel saturation to fade effect for low-saturation pixels
        let weight = common::color::cosine_weight(dist, CHANNEL_HALF_WIDTHS[i]) * pixel_sat;
        if weight > 0.0 {
            total_hue_shift = total_hue_shift + weight * params.hue_shifts[i];
            total_sat_shift = total_sat_shift + weight * (params.sat_shifts[i] / 100.0);
            total_lum_shift = total_lum_shift + weight * (params.lum_shifts[i] / 100.0);
        }
    }

    let new_hue = ((pixel_hue + total_hue_shift) % 360.0 + 360.0) % 360.0;
    let new_sat = clamp(hsl.y + total_sat_shift, 0.0, 1.0);
    let new_lum = clamp(hsl.z + total_lum_shift, 0.0, 1.0);

    return common::color::hsl_to_rgb(vec3f(new_hue, new_sat, new_lum));
}

// --- Color Grading ---

fn apply_color_grading_pixel(r: f32, g: f32, b: f32) -> vec3f {
    // Pixel luminance (Rec. 709 on gamma-encoded values)
    let lum = common::math::luminance(r, g, b);

    // Balance remapping (skip powf when balance is neutral)
    var lum_adj: f32;
    if params.cg_balance_active > 0.5 {
        lum_adj = pow(clamp(lum, 0.0, 1.0), params.cg_balance_factor);
    } else {
        lum_adj = clamp(lum, 0.0, 1.0);
    }

    // 3-way weights (always sum to 1.0) — SQUARED weights
    let w_shadow = (1.0 - lum_adj) * (1.0 - lum_adj);
    let w_highlight = lum_adj * lum_adj;
    let w_midtone = 1.0 - w_shadow - w_highlight;

    // Weighted blend of regional tints
    let regional_r = params.cg_shadow_tint.x * w_shadow + params.cg_midtone_tint.x * w_midtone + params.cg_highlight_tint.x * w_highlight;
    let regional_g = params.cg_shadow_tint.y * w_shadow + params.cg_midtone_tint.y * w_midtone + params.cg_highlight_tint.y * w_highlight;
    let regional_b = params.cg_shadow_tint.z * w_shadow + params.cg_midtone_tint.z * w_midtone + params.cg_highlight_tint.z * w_highlight;

    // Apply global tint on top
    let combined_r = regional_r * params.cg_global_tint.x;
    let combined_g = regional_g * params.cg_global_tint.y;
    let combined_b = regional_b * params.cg_global_tint.z;

    // Multiply pixel by combined tint
    var out_r = clamp(r * combined_r, 0.0, 1.0);
    var out_g = clamp(g * combined_g, 0.0, 1.0);
    var out_b = clamp(b * combined_b, 0.0, 1.0);

    // Luminance shifts (weighted additive, pre-divided by 100 via the .w component)
    let adjustment = params.cg_shadow_tint.w * w_shadow
        + params.cg_midtone_tint.w * w_midtone
        + params.cg_highlight_tint.w * w_highlight
        + params.cg_global_tint.w;
    out_r = clamp(out_r + adjustment, 0.0, 1.0);
    out_g = clamp(out_g + adjustment, 0.0, 1.0);
    out_b = clamp(out_b + adjustment, 0.0, 1.0);

    return vec3f(out_r, out_g, out_b);
}

// --- LUT ---

fn apply_lut(r: f32, g: f32, b: f32) -> vec3f {
    let coord = vec3f(clamp(r, 0.0, 1.0), clamp(g, 0.0, 1.0), clamp(b, 0.0, 1.0));
    let result = textureSampleLevel(lut_texture, lut_sampler, coord, 0.0);
    return vec3f(result.x, result.y, result.z);
}

@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) id: vec3u, @builtin(num_workgroups) nwg: vec3u) {
    let idx = id.x + id.y * nwg.x * 256u;
    let pixel_count = arrayLength(&pixels) / 3u;
    if idx >= pixel_count { return; }
    let base = idx * 3u;
    var r = pixels[base];
    var g = pixels[base + 1u];
    var b = pixels[base + 2u];

    // Contrast
    r = common::tone::apply_contrast(r, params.contrast);
    g = common::tone::apply_contrast(g, params.contrast);
    b = common::tone::apply_contrast(b, params.contrast);

    // Highlights
    r = common::tone::apply_highlights(r, params.highlights);
    g = common::tone::apply_highlights(g, params.highlights);
    b = common::tone::apply_highlights(b, params.highlights);

    // Shadows
    r = common::tone::apply_shadows(r, params.shadows);
    g = common::tone::apply_shadows(g, params.shadows);
    b = common::tone::apply_shadows(b, params.shadows);

    // Whites
    r = common::tone::apply_whites(r, params.whites);
    g = common::tone::apply_whites(g, params.whites);
    b = common::tone::apply_whites(b, params.whites);

    // Blacks
    r = common::tone::apply_blacks(r, params.blacks);
    g = common::tone::apply_blacks(g, params.blacks);
    b = common::tone::apply_blacks(b, params.blacks);

    // Tone curves
    let tc = apply_tone_curves(r, g, b);
    r = tc.x;
    g = tc.y;
    b = tc.z;

    // HSL (skip when neutral to avoid saturation clamping on HDR values)
    if params.hsl_active > 0.5 {
        let hsl_result = apply_hsl_pixel(r, g, b);
        r = hsl_result.x;
        g = hsl_result.y;
        b = hsl_result.z;
    }

    // Color grading (skip when neutral to avoid clamping HDR values)
    if params.cg_active > 0.5 {
        let cg = apply_color_grading_pixel(r, g, b);
        r = cg.x;
        g = cg.y;
        b = cg.z;
    }

    // LUT
    if params.lut_active > 0.5 {
        let lut_result = apply_lut(r, g, b);
        r = lut_result.x;
        g = lut_result.y;
        b = lut_result.z;
    }

    pixels[base] = r;
    pixels[base + 1u] = g;
    pixels[base + 2u] = b;
}