#!/usr/bin/env catnip
# Color grading et grain argentique avec pyvips
# Le pipeline reste entièrement dans libvips : aucune matérialisation NumPy.
#
# DEPS: pyvips[binary]

pyvips = import('pyvips')
math = import('math')
import('pathlib', 'Path')

script_dir = Path(META.file).parent
input_path = script_dir / 'data/catnip_test.jpg'
output_dir = script_dir / 'output'
output_dir.mkdir(exist_ok=True)

struct GradePreset {
    name: str; contrast: float; shadow_tint: list[int]; highlight_tint: list[int]; grain_amount: float;
    vignette_strength: float; seed: int;
}

# Le nom est volontairement séparé des paramètres : le match rend explicite
# la sélection d'une configuration complète et empêche les presets partiels.
preset_name = 'cinematic'
preset = match preset_name {
    'cinematic' => {
        GradePreset('cinematic', 6.0, list(-10, -2, 12), list(12, 5, -4), 7.0, 0.34, 1977)
    }
    'faded' => {
        GradePreset('faded', 4.0, list(-4, 2, 10), list(10, 6, 0), 5.0, 0.20, 1968)
    }
    'noir' => {
        GradePreset('noir', 7.0, list(-6, -6, -6), list(5, 5, 5), 9.0, 0.42, 1932)
    }
    _ => {
        GradePreset('neutral', 3.0, list(0, 0, 0), list(0, 0, 0), 3.0, 0.10, 1)
    }
}

clamp = (image, low: int, high: int) => {
    above_low = (image < low).ifthenelse(low, image)
    (above_low > high).ifthenelse(high, above_low)
}

# Construit une LUT 256 entrées. La normalisation conserve exactement les
# extrémités 0 et 255 malgré la courbe sigmoïde.
contrast_lut = (strength: float) => {
    x = pyvips.Image.identity() / 255
    sigmoid = 1 / (1 + ((x - 0.5) * -strength).exp())
    low = 1 / (1 + math.exp(strength / 2))
    high = 1 / (1 + math.exp(-strength / 2))
    ((sigmoid - low) * (255 / (high - low))).cast('uchar')
}

apply_contrast = (image, config: GradePreset) => {
    image.maplut(contrast_lut(config.contrast))
}

apply_split_toning = (image, config: GradePreset) => {
    luminance = image.colourspace('b-w')
    shadows = clamp((128 - luminance) / 128, 0, 1)
    highlights = clamp((luminance - 128) / 127, 0, 1)
    image + shadows * config.shadow_tint + highlights * config.highlight_tint
}

apply_grain = (image, config: GradePreset) => {
    luminance = image.colourspace('b-w') / 255
    shadow_bias = 0.35 + 0.65 * (1 - luminance)
    noise = pyvips.Image.gaussnoise(
        image.width,
        image.height,
        sigma=config.grain_amount,
        mean=0,
        seed=config.seed,
    )
    image + noise * shadow_bias
}

apply_vignette = (image, config: GradePreset) => {
    coordinates = pyvips.Image.xyz(image.width, image.height).bandsplit()
    x = (coordinates[0] - (image.width - 1) / 2) / (image.width / 2)
    y = (coordinates[1] - (image.height - 1) / 2) / (image.height / 2)
    radius = (x ** 2 + y ** 2) / 2
    image * (1 - config.vignette_strength * radius)
}

print("⇒ Chargement de l'en-tête")
source = pyvips.Image.new_from_file(str(input_path), access='sequential').autorot().colourspace('srgb')
print(f"  {source.width}×{source.height}, {source.bands} bandes, format {source.format}")

print()
print(f"⇒ Construction du pipeline lazy « {preset.name} »")
graded = apply_contrast(source, preset)
graded = apply_split_toning(graded, preset)
graded = apply_grain(graded, preset)
graded = apply_vignette(graded, preset)
graded = clamp(graded, 0, 255).cast('uchar')
print("  LUT → split toning → grain → vignettage")
print("  aucun pixel n'est encore écrit")

graded_path = output_dir / f'pyvips_{preset.name}_graded.jpg'
print()
print("⇒ Évaluation du pipeline à l'écriture")
graded.write_to_file(str(graded_path), Q=92, strip=True, optimize_coding=True)
print(f"  résultat → {graded_path.name}")

# Deux chargements indépendants permettent à chaque branche de la planche de
# rester séquentielle. Partager la source déjà évaluée imposerait des lectures
# arrière incompatibles avec access='sequential'.
original_preview = pyvips.Image.new_from_file(str(input_path), access='sequential').autorot().colourspace('srgb')
graded_preview = pyvips.Image.new_from_file(str(graded_path), access='sequential').colourspace('srgb')
comparison = pyvips.Image.arrayjoin(
    list(original_preview, graded_preview),
    across=2,
    shim=12,
    background=list(245, 245, 245),
)
comparison_path = output_dir / f'pyvips_{preset.name}_comparison.jpg'
comparison.write_to_file(str(comparison_path), Q=92, strip=True, optimize_coding=True)
print(f"  avant/après → {comparison_path.name}")

print()
print(f"⇒ Sorties disponibles dans {output_dir}")