collective

Browse / github.com/storytold/photocraft / patch

fix: match Photoshop RGB8 stacked adjustment rendering

fix: match Photoshop RGB8 stacked adjustment rendering ## Summary Match Photoshop 26.11.6 for the clipped RGB/8 Brightness/Contrast and Hue/Saturation stacks reported in #9633. Refs #9633. This branch includes the pending Brightness/Contrast fix from #9352 and Hue/Saturation fix from #9494, then adds the stacked-adjustment repair in `ce06de99c`. The incremental stacked repair changes five source/test files; the complete PR changes six implementation and Rust regression-test files. ## Key changes - Calibrate modern Brightness/Contrast curves and preserve negative and above-white float samples for neutral adjustments. - Extend the RGB/8

Status
open
Score
0.00
Adopters
0 other operators
Author
wojzj57 (imported from pull request #9638, open)
Base
7ac7b8b106acccf6826d45ed5c9e481dfe341035
Size
+793 −28 in 9 files
Review
pending
Shared
2026-10-28
Id
9966d4381a9218026b8c28f3

How it went

No other operator has reported using it yet.

Diff

raw · crates/compose/src/adjust.rs crates/compose/src/adjust/channels.rs crates/compose/src/adjust/color.rs crates/compose/src/adjust/tone.rs crates/compose/tests/brightness_contrast.rs crates/compose/tests/stacked_adjustments.rs crates/gpu/src/compose.wgsl crates/gpu/src/plan/lower.rs crates/gpu/tests/it/parity.rs

diff --git a/crates/compose/src/adjust.rs b/crates/compose/src/adjust.rs--- a/crates/compose/src/adjust.rs+++ b/crates/compose/src/adjust.rs@@ -21,12 +21,14 @@ pub use channels::{     lab_stored_op, stored_channel_adjustment, stored_channels, threshold_white, }; use channels::{apply_lab, legacy_brightness_contrast, legacy_brightness_contrast_hdr};-use color::gradient;+#[cfg(test)]+use color::rgb8_hue_ratio; pub use color::{     BalanceCurve, VIBRANCE_BOOST, VIBRANCE_GREY, bayer4, black_white_gray, color_balance_channel, colorize_saturation, hdr_saturation_factor, hsl_to_rgb,     hue_range_tables, hue_saturation, hue_saturation_in, lut3d_sample, photo_filter_matrix, range_lightness, rgb_to_hsl, selective_color,     selective_color_weights, vibrance_lin, vibrance_px, };+use color::{gradient, hue_saturation_rgb8, rgb8_lightness_levels}; #[cfg(test)] use tone::LUT_SIZE; pub(crate) use tone::apply_tone;@@ -107,7 +109,8 @@ pub fn apply_depth(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth }  /// [`apply_depth`] in a document of colour `mode` (which decides whether float curves extend,-/// see [`float_tone_extrapolates`]).+/// see [`float_tone_extrapolates`]). Neutral-range RGB/8 Hue/Saturation at nonpositive hue uses+/// whole channel levels; other depths, modes, slider domains and colour ranges use the float path. fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: Option<SampleType>, mode: ColorMode) {     match adj {         Adjustment::Invert => map_rgb(buf, |c| [1.0 - c[0], 1.0 - c[1], 1.0 - c[2]]),@@ -128,6 +131,10 @@ fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, dept             }         }         Adjustment::BrightnessContrast { brightness, contrast, .. } => {+            // A neutral adjustment must preserve negative and above-white float samples.+            if *brightness == 0.0 && *contrast == 0.0 {+                return;+            }             // Modern (CS3+) Brightness/Contrast, reverse-engineered from Photoshop ground truth.             // Unlike the legacy linear scale, both are smooth curves that pin pure black and white             // (see `modern_brightness` and `modern_contrast`), applied brightness then contrast@@ -179,6 +186,39 @@ fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, dept         }         Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {             let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));+            let rgb8 = mode == ColorMode::Rgb+                && depth == Some(SampleType::U8)+                && !*colorize+                && table.is_none()+                && hue.is_finite()+                && (-180.0..=0.0).contains(hue)+                && saturation.is_finite()+                && (-100.0..=100.0).contains(saturation)+                && lightness.is_finite()+                && (-100.0..=100.0).contains(lightness);+            if rgb8 {+                let sat = (*saturation / 100.0).clamp(-1.0, 1.0);+                // RGB/8 Lightness keeps only whole 1/255 slider levels. Photoshop's+                // +41 and -16 ramps both use floor(abs(percent) * 255 / 100).+                let light = (*lightness / 100.0).clamp(-1.0, 1.0);+                let light = light.signum() * (light.abs() * 255.0 + 1e-4).floor() / 255.0;+                if *hue == 0.0 && sat == 0.0 && light == 0.0 {+                    return;+                }+                let factor = if sat < 0.0 {+                    256 - (-sat * 256.0 + 1e-4).floor() as i32+                } else {+                    let steps = ((sat * 254.0 + 1e-4).floor() as i32).clamp(0, 254);+                    (65280 / (255 - steps)).min(32767)+                };+                // The quarter-step phase matches measured RGB/8 slider positions at -62,+                // -138 and -158; the sector-crossing rule below handles their endpoints.+                let shift = if *hue == 0.0 { 0 } else { (*hue * 256.0 / 60.0 - 0.25).floor() as i32 };+                let rotated = *hue != 0.0;+                let lightness_levels = rgb8_lightness_levels(light);+                map_rgb(buf, |c| hue_saturation_rgb8(c, factor, shift, rotated, light, &lightness_levels));+                return;+            }             map_rgb(buf, |c| {                 let (dh, ds, dl) = match &table {                     Some(t) => {@@ -191,7 +231,8 @@ fn apply_depth_mode(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, dept                     None => (0.0, 0.0, 0.0),                 };                 let sat = colorize_saturation((*saturation / 100.0 + ds).clamp(-1.0, 1.0), *lightness / 100.0, *colorize, depth);-                let out = hue_saturation_in(c, *hue + dh, sat, (*lightness / 100.0).clamp(-1.0, 1.0), *colorize, depth);+                let light = (*lightness / 100.0).clamp(-1.0, 1.0);+                let out = hue_saturation_in(c, *hue + dh, sat, light, *colorize, depth);                 range_lightness(out, dl)             })         }@@ -326,6 +367,7 @@ pub fn apply_in(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer, depth: O #[cfg(test)] mod lookup_tests {     use super::*;+    use photocraft_geom::Rect;      fn identity(n: usize) -> Vec<f32> {         let m = (n - 1) as f32;@@ -345,6 +387,148 @@ mod lookup_tests {         }     } +    /// Photoshop 26.11.6 RGB/8 pixels from the clipped Hue/Saturation case+    /// `image-1-hs` (source PNG and independently exported Photoshop PNG).+    #[test]+    fn clipped_hue_saturation_matches_photoshop_rgb8_pixels() {+        let measured: [([u8; 3], [u8; 3]); 8] = [+            ([1, 34, 65], [109, 100, 46]),+            ([1, 83, 133], [169, 136, 39]),+            ([108, 159, 180], [202, 178, 130]),+            ([74, 141, 183], [207, 180, 99]),+            ([84, 138, 166], [192, 169, 110]),+            ([12, 19, 25], [72, 70, 60]),+            ([170, 184, 193], [207, 202, 185]),+            ([143, 178, 198], [214, 199, 159]),+        ];+        let adjustment = Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() };+        for (input, expected) in measured {+            let px = input.map(|channel| channel as f32 / 255.0);+            let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[px[0], px[1], px[2], 1.0]] };+            apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));+            let actual: [u8; 3] = std::array::from_fn(|i| (buffer.px[0][i] * 255.0).round() as u8);+            assert_eq!(actual, expected, "input {input:?}");+        }+    }++    /// Pure secondary RGB/8 colours select the descending hue sector at a shared maximum,+    /// matching Photoshop's Saturation-only and Lightness-only channel tie outputs.+    #[test]+    fn rgb8_hue_saturation_resolves_primary_ties_like_photoshop() {+        for (saturation, lightness, expected) in [+            (20.0, 0.0, [[254, 255, 0], [0, 254, 255], [255, 0, 254]]),+            (0.0, 20.0, [[254, 255, 51], [51, 254, 255], [255, 51, 254]]),+            (20.0, 20.0, [[254, 255, 51], [51, 254, 255], [255, 51, 254]]),+        ] {+            let adjustment = Adjustment::HueSaturation { hue: 0.0, saturation, lightness, colorize: false, ranges: HueRange::defaults() };+            for (input, want) in [[255, 255, 0], [0, 255, 255], [255, 0, 255]].into_iter().zip(expected) {+                let rgb = input.map(|v| v as f32 / 255.0);+                let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[rgb[0], rgb[1], rgb[2], 1.0]] };+                apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));+                let got: [u8; 3] = std::array::from_fn(|i| (buffer.px[0][i] * 255.0).round() as u8);+                assert_eq!(got, want, "{input:?}, s{saturation} l{lightness}");+            }+        }+    }++    /// Fifth-step percentages retain their prior scalar rounding. Other signed percentages+    /// follow measured Photoshop RGB/8 grey samples rather than unquantized scalar arithmetic.+    #[test]+    fn rgb8_lightness_levels_match_photoshop_rounding() {+        let source: Vec<[f32; 4]> = (0..=255).map(|v| [v as f32 / 255.0, v as f32 / 255.0, v as f32 / 255.0, 1.0]).collect();+        for percent in [0, 20, 40, 60, 80, 100] {+            let lightness = percent as f32 / 100.0;+            let adjustment =+                Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: percent as f32, colorize: false, ranges: HueRange::defaults() };+            let mut buffer = Buffer { rect: Rect::new(0, 0, 256, 1), px: source.clone() };+            apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));+            for (v, pixel) in buffer.px.iter().enumerate() {+                let expected = (v as f32 + (255 - v) as f32 * lightness).round() as u8;+                let actual: [u8; 3] = std::array::from_fn(|channel| (pixel[channel] * 255.0).round() as u8);+                assert_eq!(actual, [expected; 3], "lightness {percent}%, level {v}");+            }+        }+        let measured = [(41.0, [104, 105, 107, 113, 123, 142, 161, 180, 199, 218, 237, 254]), (-16.0, [0, 1, 4, 13, 27, 54, 81, 108, 135, 162, 189, 214])];+        let levels = [0, 1, 5, 16, 32, 64, 96, 128, 160, 192, 224, 254];+        for (lightness, expected) in measured {+            let adjustment = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness, colorize: false, ranges: HueRange::defaults() };+            for (level, want) in levels.into_iter().zip(expected) {+                let v = level as f32 / 255.0;+                let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[v, v, v, 1.0]] };+                apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));+                let actual: [u8; 3] = std::array::from_fn(|channel| (buffer.px[0][channel] * 255.0).round() as u8);+                assert_eq!(actual, [want; 3], "lightness {lightness}%, level {level}");+            }+        }+    }++    /// A neutral adjustment leaves fractional working pixels untouched, while high depths,+    /// non-RGB documents and out-of-slider values keep their existing float behavior.+    #[test]+    fn rgb8_hue_saturation_branch_preserves_identity_and_other_depths() {+        let fractional = [0.12345, 0.45678, 0.78901, 0.375];+        let neutral = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() };+        let mut identity = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![fractional] };+        apply_depth(&neutral, &mut identity, Transfer::Srgb, Some(SampleType::U8));+        assert_eq!(identity.px[0], fractional, "neutral adjustment must preserve fractional working pixels");++        let adjusted = Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() };+        for depth in [SampleType::U16, SampleType::F32] {+            let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![fractional] };+            apply_depth(&adjusted, &mut buffer, Transfer::Srgb, Some(depth));+            let expected = hue_saturation_in([fractional[0], fractional[1], fractional[2]], -158.0, 0.2, 0.2, false, Some(depth));+            for (got, want) in buffer.px[0][..3].iter().zip(expected) {+                assert!((got - want).abs() < 1e-6, "{depth:?}: {got} vs {want}");+            }+            assert_eq!(buffer.px[0][3], fractional[3]);+        }+        for (mode, saturation, lightness) in [(ColorMode::Rgb, 120.0, 20.0), (ColorMode::Rgb, 20.0, 120.0), (ColorMode::Lab, 20.0, 20.0)] {+            let adjustment = Adjustment::HueSaturation { hue: -158.0, saturation, lightness, colorize: false, ranges: HueRange::defaults() };+            let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![fractional] };+            apply_depth_mode(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8), mode);+            let expected = hue_saturation_in(+                [fractional[0], fractional[1], fractional[2]],+                -158.0,+                (saturation / 100.0).clamp(-1.0, 1.0),+                (lightness / 100.0).clamp(-1.0, 1.0),+                false,+                Some(SampleType::U8),+            );+            for (got, want) in buffer.px[0][..3].iter().zip(expected) {+                assert!((got - want).abs() < 1e-6, "{mode:?} s{saturation} l{lightness}: {got} vs {want}");+            }+        }+    }++    /// The bounded RGB/8 float quotient must equal signed integer division for every possible+    /// source hue numerator and nonzero chroma (32,895 magnitudes, checked in both signs).+    #[test]+    fn rgb8_hue_ratio_float_division_matches_integer_for_all_channel_differences() {+        for chroma in 1..=255 {+            for numerator in 0..=chroma {+                let want = (numerator * 255 + chroma / 2) / chroma;+                assert_eq!(rgb8_hue_ratio(numerator, chroma), want, "chroma {chroma}, numerator {numerator}");+                assert_eq!(rgb8_hue_ratio(-numerator, chroma), -want, "chroma {chroma}, numerator -{numerator}");+            }+        }+    }++    /// A +40-degree RGB/8 adjustment retains the earlier float path on the measured red ramp+    /// pixel, where its green result is one level closer to Photoshop than the integer phase.+    #[test]+    fn positive_hue_rgb8_keeps_the_existing_float_path() {+        // Photoshop's +40° ramp sample: the existing float path is one channel level closer+        // than the new integer path, whose empirically fitted phase is specific to nonpositive hue.+        let input = [255.0 / 255.0, 1.0 / 255.0, 0.0];+        let adjustment = Adjustment::HueSaturation { hue: 40.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() };+        let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[input[0], input[1], input[2], 1.0]] };+        apply_depth(&adjustment, &mut buffer, Transfer::Srgb, Some(SampleType::U8));+        let old = hue_saturation_in(input, 40.0, 0.2, 0.2, false, Some(SampleType::U8));+        assert_eq!(&buffer.px[0][..3], &old);+        let actual: [u8; 3] = std::array::from_fn(|i| (buffer.px[0][i] * 255.0).round() as u8);+        assert_eq!(actual, [255, 188, 51]);+    }+     /// Photoshop 27.11, measured: absolute Selective Color on greys (red channel after one range's     /// cyan change).     #[test]diff --git a/crates/compose/src/adjust/channels.rs b/crates/compose/src/adjust/channels.rs--- a/crates/compose/src/adjust/channels.rs+++ b/crates/compose/src/adjust/channels.rs@@ -240,6 +240,9 @@ pub(super) fn apply_lab(adj: &Adjustment, buf: &mut Buffer, depth: Option<Sample         }         Adjustment::BrightnessContrast { brightness, contrast, legacy } => {             let legacy_curve = legacy_brightness_contrast(*brightness, *contrast);+            if !*legacy && *brightness == 0.0 && *contrast == 0.0 {+                return;+            }             let modern = (!*legacy).then(|| modern_lut(*brightness, *contrast));             let curve = |v: f32| match &modern {                 Some(table) => lut(table, v),diff --git a/crates/compose/src/adjust/color.rs b/crates/compose/src/adjust/color.rs--- a/crates/compose/src/adjust/color.rs+++ b/crates/compose/src/adjust/color.rs@@ -65,6 +65,78 @@ pub fn hue_saturation_in(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool,     hsl_to_rgb(hh, ss, ll) } +/// Evaluates one RGB/8 pixel using whole channel and hue levels for the neutral-range master+/// adjustment at hue -180..=0 degrees. `factor` is chroma+/// gain in 1/256 units, `shift` is a 1/256-sector hue offset, and `lightness_levels` contains+/// the rounded channel levels after Lightness. The 256-step sectors have 255 source positions.+/// Measured against the clipped Photoshop 26.11.6 RGB/8 source/PSD oracle (`image-1-hs`).+pub(super) fn hue_saturation_rgb8(c: [f32; 3], factor: i32, shift: i32, rotated: bool, lightness: f32, lightness_levels: &[i32; 256]) -> [f32; 3] {+    let rgb = c.map(|v| (v.clamp(0.0, 1.0) * 255.0).round() as i32);+    let rgb = rgb.map(|v| lightness_levels.get(v as usize).copied().unwrap_or_else(|| rgb8_lightness(v as f32, lightness).round() as i32));+    let (r, g, b) = (rgb[0], rgb[1], rgb[2]);+    let (minimum, maximum) = (r.min(g).min(b), r.max(g).max(b));+    let old_chroma = maximum - minimum;+    if old_chroma == 0 {+        return [minimum as f32 / 255.0; 3];+    }+    let sum = minimum + maximum;+    let chroma = ((old_chroma * factor + 127) / 256).min(sum.min(510 - sum));+    let low = (sum - chroma) / 2;+    let high = (sum + chroma) / 2;++    // At a shared maximum, choose the next primary around the hue wheel. This puts yellow,+    // cyan and magenta on the same descending sector edge as Photoshop's RGB/8 oracle.+    let (base, numerator) = if r > g && r >= b {+        (0, g - b)+    } else if g > b && g >= r {+        (512, b - r)+    } else {+        (1024, r - g)+    };+    let ratio = rgb8_hue_ratio(numerator, old_chroma);+    // The rotated primary identifies the sector before the source hue's signed offset. Only a+    // source hue that crosses that sector boundary needs the extra endpoint step.+    let primary_sector = (base + shift).rem_euclid(1536) / 256;+    let initial = (base + ratio + shift).rem_euclid(1536);+    let initial_sector = initial / 256;+    let phase = if rotated && numerator > 0 && initial_sector != primary_sector && initial_sector % 2 == 0 { 2 } else { 0 };+    let position = (initial + phase).rem_euclid(1536);+    let sector = position / 256;+    let offset = position % 256;+    let rising = low + (chroma * offset + 127) / 256;+    // Crossing into the preceding descending sector uses its full endpoint. A source hue that+    // stays in the rotated primary's sector uses the usual 254-step endpoint.+    let falling_span = if !rotated || (numerator < 0 && initial_sector != primary_sector) { 256 } else { 254 };+    let falling = low + (chroma * (falling_span - offset) + 127).div_euclid(256);+    let out = match sector {+        0 => [high, rising, low],+        1 => [falling, high, low],+        2 => [low, high, rising],+        3 => [low, falling, high],+        4 => [rising, low, high],+        _ => [high, low, falling],+    };+    out.map(|v| v.clamp(0, 255) as f32 / 255.0)+}++/// Maps each 0..=255 RGB/8 channel level through signed Lightness once per adjustment.+/// Each entry preserves the f32 operation order and rounding of the channel transform.+pub(super) fn rgb8_lightness_levels(lightness: f32) -> [i32; 256] {+    std::array::from_fn(|v| rgb8_lightness(v as f32, lightness).round() as i32)+}++fn rgb8_lightness(value: f32, lightness: f32) -> f32 {+    if lightness >= 0.0 { value + (255.0 - value) * lightness } else { value * (1.0 + lightness) }+}++/// Rounded position within a 255-step source hue sector. The numerator is at most 65,152,+/// exactly representable in f32; a nonintegral quotient is at least 1/255 from either integer.+/// Direct division and floor therefore produce the same quotient as integer division for RGB/8.+pub(super) fn rgb8_hue_ratio(numerator: i32, chroma: i32) -> i32 {+    let rounded_numerator = numerator.abs() * 255 + chroma / 2;+    numerator.signum() * ((rounded_numerator as f32 / chroma as f32).floor() as i32)+}+ /// The factor 32-bit Hue/Saturation scales chroma by for saturation `s` (-1..1), as Photoshop /// computes it (measured at every whole percent from -100 to +100 on a 32-bit document): lowering /// cuts `s` to 1/256 steps, raising cuts it to 254 steps out of 255, takes 255 / (255 - steps) todiff --git a/crates/compose/src/adjust/tone.rs b/crates/compose/src/adjust/tone.rs--- a/crates/compose/src/adjust/tone.rs+++ b/crates/compose/src/adjust/tone.rs@@ -141,17 +141,11 @@ fn lut_extended(table: &[f32], v: f32) -> f32 {     if v < 0.0 { table[0] + v * (table[k] - table[0]) * per_unit } else { table[n - 1] + (v - 1.0) * (table[n - 1] - table[n - 1 - k]) * per_unit } } -/// Modern Brightness curve (one channel, `brightness` in [-150, 150]); pins pure black and white,-/// and `b = 0` is the identity. Raised, it is a line of slope `s = 1.375^(b/50)` from the origin-/// up to a knee at output 126/255, then a cubic Hermite to (1, 1) whose end slope is-/// `1 / (1 + 0.084 b)`; past about +110, where no such cubic stays below white, the roll-off is-/// `1 - (1 - y0)(1 - u)^α` instead (continuous slope `α` at the knee, flat at white). Lowered, it-/// is the inverse of the curve raised by as much.-///-/// Fit to every Photoshop measurement we have, all within about a level and a half: its ±50-/// samples (`modern_brightness_matches_photoshop`), the photoshop corpus's brightness-contrast.psd-/// (+25 with contrast +30) and psd-tools' brightnesscontrast_rgb.psd, whose masked layers give-/// +23, -31 and -95 (+150 within 6 levels there, where every curve family tried bottoms out).+/// Modern Brightness curve, fitted to Photoshop 26.11.6 RGB/8 and RGB/16 ramps.+/// Raised: a line of slope `2^(b/110)` up to output 0.5, then a Hermite roll-off to white+/// with endpoint slope `max(0.1, 1/(12s-11))`. Lowered: the inverse of the raised curve.+/// Very high positive brightness uses a monotone power roll-off instead of an overshooting+/// cubic; that fallback remains an approximation of Photoshop's highlight curve. pub fn modern_brightness(v: f32, brightness: f32) -> f32 {     if brightness == 0.0 || !brightness.is_finite() {         return v;@@ -172,13 +166,15 @@ struct Brightness { }  /// Output level of the Brightness curve's knee.-const BRIGHTNESS_KNEE: f32 = 126.0 / 255.0;+const BRIGHTNESS_KNEE: f32 = 0.5;  impl Brightness {+    /// Precomputes the capped slider's slope, knee and white-endpoint parameters.+    /// Selects a monotone power roll-off when the Hermite slopes risk overshoot.     fn new(brightness: f32) -> Self {-        let b = brightness.abs();-        let s = 1.375f32.powf(b / 50.0);-        let e = 1.0 / (1.0 + 0.084 * b);+        let b = brightness.abs().min(150.0);+        let s = (b / 110.0).exp2();+        let e = (1.0 / (12.0 * s - 11.0)).max(0.1);         let x0 = (BRIGHTNESS_KNEE / s).min(1.0);         let secant = (1.0 - BRIGHTNESS_KNEE) / (1.0 - x0).max(1e-6);         let (alpha, beta) = (s / secant, e / secant);@@ -243,6 +239,10 @@ pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {         (0..LUT_SIZE)             .map(|k| {                 let y = step(k, LUT_SIZE);+                // Raised highlights can round to white before the final sample.+                if k == 0 || k + 1 == LUT_SIZE {+                    return y;+                }                 while j + 2 < fine && ys.get(j + 1).is_some_and(|&n| n < y) {                     j += 1;                 }@@ -255,19 +255,16 @@ pub fn modern_lut(brightness: f32, contrast: f32) -> Vec<f32> {     bright.into_iter().map(|b| modern_contrast(b, contrast).clamp(0.0, 1.0)).collect() } -/// Modern Contrast curve (one channel, `contrast` in [-50, 100]). A symmetric cubic-Hermite S-curve-/// pivoting at 0.5: endpoint slope `1 - c/128`, centre slope `1 + c/128`. Near-exact vs Photoshop-/// (≤0.5/255). `c = 0` is the identity; pins 0, 0.5 and 1.+/// Modern Contrast: two symmetric quadratic segments, pinned at black, middle gray and white.+/// Photoshop 26.11.6 high-precision ramps give `k = 0.0076c`; `c/128` drifts across byte thresholds. pub fn modern_contrast(v: f32, contrast: f32) -> f32 {-    if contrast == 0.0 {+    if contrast == 0.0 || !contrast.is_finite() {         return v;     }-    let k = contrast / 128.0;-    let (end, mid) = (1.0 - k, 1.0 + k);-    // Cubic Hermite on [0, 0.5]: pinned (0,0) slope `end`, (0.5,0.5) slope `mid`. `t` in [0,1].-    let half = |t: f32| end * 0.5 * (t * t * t - 2.0 * t * t + t) + (-2.0 * t * t * t + 3.0 * t * t) * 0.5 + mid * 0.5 * (t * t * t - t * t);+    let k = contrast.clamp(-50.0, 100.0) * 0.0076;+    let half = |x: f32| x + k * (2.0 * x * x - x);     let v = v.clamp(0.0, 1.0);-    if v <= 0.5 { half(v / 0.5) } else { 1.0 - half((1.0 - v) / 0.5) }+    if v <= 0.5 { half(v) } else { 1.0 - half(1.0 - v) } }  /// Photoshop posterize: `n` equal input bins over 0..=255, output levelsdiff --git a/crates/compose/tests/brightness_contrast.rs b/crates/compose/tests/brightness_contrast.rsnew file mode 100644--- /dev/null+++ b/crates/compose/tests/brightness_contrast.rs@@ -0,0 +1,172 @@+//! Black-box Photoshop 26.11.6 measurements, RGB/8, generated sRGB gray ramp.+//! Each pixel is (x, x, x), x=0..255. Measurements taken through PS Generator Bridge.+//! The issue's (64,64,64), brightness 75, contrast -17 must become (105,105,105).++use photocraft_compose::{Buffer, adjust};+use photocraft_doc::Adjustment;+use photocraft_geom::Rect;++const PHOTOSHOP: [(f32, f32, [u8; 256]); 3] = [+    (+        75.0,+        -17.0,+        [+            0, 2, 4, 5, 7, 9, 11, 13, 14, 16, 18, 20, 21, 23, 25, 27, 28, 30, 32, 33, 35, 37, 39, 40, 42, 44, 45, 47, 49, 50, 52, 54, 55, 57, 59, 60, 62, 63,+            65, 67, 68, 70, 71, 73, 75, 76, 78, 79, 81, 82, 84, 86, 87, 89, 90, 92, 93, 95, 96, 98, 99, 101, 102, 104, 105, 107, 108, 110, 111, 113, 114, 115,+            117, 118, 120, 121, 123, 124, 125, 127, 128, 130, 131, 132, 134, 135, 136, 138, 139, 141, 142, 143, 144, 146, 147, 148, 150, 151, 152, 153, 155,+            156, 157, 158, 160, 161, 162, 163, 165, 166, 167, 168, 169, 170, 172, 173, 174, 175, 176, 177, 178, 179, 180, 182, 183, 184, 185, 186, 187, 188,+            189, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 199, 200, 201, 202, 203, 204, 205, 206, 207, 207, 208, 209, 210, 211, 212, 212, 213, 214,+            215, 215, 216, 217, 218, 218, 219, 220, 221, 221, 222, 223, 223, 224, 225, 225, 226, 227, 227, 228, 228, 229, 230, 230, 231, 231, 232, 233, 233,+            234, 234, 235, 235, 236, 236, 237, 237, 238, 238, 239, 239, 240, 240, 240, 241, 241, 242, 242, 243, 243, 243, 244, 244, 244, 245, 245, 246, 246,+            246, 247, 247, 247, 248, 248, 248, 248, 249, 249, 249, 250, 250, 250, 250, 251, 251, 251, 251, 252, 252, 252, 252, 252, 253, 253, 253, 253, 253,+            254, 254, 254, 254, 254, 254, 255, 255, 255, 255,+        ],+    ),+    (+        75.0,+        0.0,+        [+            0, 2, 3, 5, 6, 8, 10, 11, 13, 14, 16, 18, 19, 21, 22, 24, 26, 27, 29, 30, 32, 34, 35, 37, 38, 40, 42, 43, 45, 47, 48, 50, 51, 53, 55, 56, 58, 59,+            61, 63, 64, 66, 67, 69, 71, 72, 74, 75, 77, 79, 80, 82, 83, 85, 87, 88, 90, 91, 93, 95, 96, 98, 99, 101, 103, 104, 106, 107, 109, 111, 112, 114,+            115, 117, 119, 120, 122, 124, 125, 127, 128, 130, 131, 133, 135, 136, 138, 139, 141, 142, 144, 145, 147, 148, 149, 151, 152, 154, 155, 156, 158,+            159, 160, 162, 163, 164, 166, 167, 168, 169, 171, 172, 173, 174, 175, 177, 178, 179, 180, 181, 182, 183, 185, 186, 187, 188, 189, 190, 191, 192,+            193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 203, 204, 205, 206, 207, 208, 209, 209, 210, 211, 212, 213, 214, 214, 215, 216, 217, 217,+            218, 219, 220, 220, 221, 222, 222, 223, 224, 224, 225, 226, 226, 227, 227, 228, 229, 229, 230, 230, 231, 232, 232, 233, 233, 234, 234, 235, 235,+            236, 236, 237, 237, 238, 238, 239, 239, 239, 240, 240, 241, 241, 242, 242, 242, 243, 243, 243, 244, 244, 245, 245, 245, 246, 246, 246, 247, 247,+            247, 247, 248, 248, 248, 249, 249, 249, 249, 250, 250, 250, 250, 251, 251, 251, 251, 252, 252, 252, 252, 252, 253, 253, 253, 253, 253, 253, 254,+            254, 254, 254, 254, 254, 254, 255, 255, 255, 255,+        ],+    ),+    (+        0.0,+        -17.0,+        [+            0, 1, 2, 3, 5, 6, 7, 8, 9, 10, 11, 12, 13, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41,+            42, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79,+            80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, 104, 105, 106, 106, 107, 108, 109, 110,+            111, 112, 113, 114, 115, 116, 116, 117, 118, 119, 120, 121, 122, 123, 124, 124, 125, 126, 127, 128, 129, 130, 131, 131, 132, 133, 134, 135, 136,+            137, 138, 139, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 149, 150, 151, 152, 153, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163,+            163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 183, 184, 185, 186, 187, 188, 189, 190, 191,+            192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 213, 214, 215, 216, 217, 218, 219, 220, 221,+            222, 223, 224, 225, 226, 227, 228, 230, 231, 232, 233, 234, 235, 236, 237, 238, 239, 240, 242, 243, 244, 245, 246, 247, 248, 249, 250, 252, 253,+            254, 255,+        ],+    ),+];++/// Matches all 256 RGB/8 gray-ramp samples to the measured Photoshop settings,+/// including the issue's brightness 75 / contrast -17 rounding regression.+#[test]+fn modern_brightness_contrast_matches_photoshop_ramps_exactly() {+    for (brightness, contrast, expected) in PHOTOSHOP {+        let mut buffer = Buffer { rect: Rect::new(0, 0, 256, 1), px: (0..256).map(|x| [x as f32 / 255.0, x as f32 / 255.0, x as f32 / 255.0, 1.0]).collect() };+        adjust::apply(&Adjustment::BrightnessContrast { brightness, contrast, legacy: false }, &mut buffer);+        for (x, (pixel, expected)) in buffer.to_rgba8().pixels.chunks_exact(4).zip(expected).enumerate() {+            assert_eq!(pixel, &[expected, expected, expected, 255], "b={brightness} c={contrast} input={x}");+        }+    }+}++/// A neutral modern adjustment must preserve negative and above-white F32 samples+/// and alpha exactly, rather than clipping through the lookup table's 0..1 domain.+#[test]+fn neutral_modern_brightness_contrast_preserves_float_range() {+    use photocraft_color::SampleType;+    let mut buffer = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[2.0, -0.25, 1.5, 0.4]] };+    let before = buffer.clone();+    adjust::apply_depth(+        &Adjustment::BrightnessContrast { brightness: 0.0, contrast: 0.0, legacy: false },+        &mut buffer,+        adjust::Transfer::Gamma(1.0),+        Some(SampleType::F32),+    );+    assert_eq!(buffer, before);+}++/// Checks the measured RGB/8 result for a partially transparent pixel while+/// preserving its alpha and leaving a fully transparent pixel's hidden RGB intact.+#[test]+fn modern_brightness_contrast_preserves_alpha_and_transparent_rgb() {+    let source = [[64.0 / 255.0, 33.0 / 255.0, 0.0, 0.4], [0.2, 0.3, 0.4, 0.0]];+    let mut buffer = Buffer { rect: Rect::new(0, 0, 2, 1), px: source.to_vec() };+    adjust::apply(&Adjustment::BrightnessContrast { brightness: 75.0, contrast: -17.0, legacy: false }, &mut buffer);+    assert_eq!(&buffer.to_rgba8().pixels[..4], &[105, 57, 0, 102]);+    assert_eq!(buffer.px[0][3], source[0][3]);+    assert_eq!(buffer.px[1], source[1]);+}++// RGB/16 PNG samples (stored as 0..65535); Photoshop internally uses 0..32768.+const INPUT16: [u16; 17] = [0, 4096, 8192, 12288, 16384, 20480, 24576, 28672, 32768, 36863, 40959, 45055, 49151, 53247, 57343, 61439, 65535];+const PHOTOSHOP16: [(f32, f32, [u16; 17]); 11] = [+    (25.0, 0.0, [0, 4794, 9590, 14384, 19180, 23974, 28770, 33563, 38307, 42923, 47337, 51477, 55269, 58645, 61529, 63849, 65535]),+    (50.0, 0.0, [0, 5612, 11226, 16838, 22452, 28064, 33671, 38983, 43835, 48223, 52141, 55587, 58553, 61037, 63031, 64531, 65535]),+    (75.0, 0.0, [0, 6570, 13142, 19712, 26282, 32853, 38993, 44335, 48933, 52841, 56113, 58809, 60981, 62683, 63973, 64905, 65535]),+    (100.0, 0.0, [0, 7692, 15384, 23076, 30766, 38119, 44333, 49473, 53649, 56973, 59551, 61495, 62915, 63919, 64617, 65119, 65535]),+    (-50.0, 0.0, [0, 2990, 5978, 8968, 11956, 14946, 17934, 20924, 23912, 26996, 30294, 33863, 37791, 42213, 47399, 53999, 65535]),+    (0.0, -50.0, [0, 5458, 10526, 15206, 19496, 23398, 26910, 30034, 32768, 35501, 38625, 42137, 46039, 50329, 55009, 60077, 65535]),+    (0.0, -17.0, [0, 4560, 8986, 13280, 17442, 21472, 25370, 29136, 32768, 36399, 40165, 44063, 48093, 52255, 56549, 60975, 65535]),+    (0.0, 25.0, [0, 3416, 7024, 10828, 14828, 19020, 23408, 27992, 32768, 37543, 42127, 46515, 50707, 54707, 58511, 62119, 65535]),+    (0.0, 50.0, [0, 2734, 5858, 9370, 13272, 17562, 22242, 27310, 32768, 38225, 43293, 47973, 52263, 56165, 59677, 62801, 65535]),+    (0.0, 100.0, [0, 1372, 3522, 6452, 10158, 14644, 19906, 25948, 32768, 39587, 45629, 50891, 55377, 59083, 62013, 64163, 65535]),+    (75.0, -17.0, [0, 7250, 14158, 20726, 26954, 32841, 38341, 43369, 47873, 51835, 55247, 58119, 60473, 62347, 63781, 64825, 65535]),+];++/// Compares RGB/16 ramps to Photoshop within two internal 1/32768 steps,+/// guarding against byte quantization without claiming exact RGB/16 parity.+#[test]+fn modern_brightness_contrast_retains_16_bit_precision() {+    use photocraft_color::SampleType;+    for (brightness, contrast, expected) in PHOTOSHOP16 {+        let mut buffer =+            Buffer { rect: Rect::new(0, 0, 17, 1), px: INPUT16.iter().map(|&x| [x as f32 / 65535.0, x as f32 / 65535.0, x as f32 / 65535.0, 1.0]).collect() };+        adjust::apply_depth(+            &Adjustment::BrightnessContrast { brightness, contrast, legacy: false },+            &mut buffer,+            adjust::Transfer::Srgb,+            Some(SampleType::U16),+        );+        for (&pixel, expected) in buffer.px.iter().zip(expected) {+            assert!((pixel[0] - expected as f32 / 65535.0).abs() <= 2.0 / 32768.0, "b={brightness} c={contrast} pixel={pixel:?} PS={expected}");+            assert_eq!(pixel[3], 1.0);+        }+    }+}++/// Checks LUT monotonicity and exact black/white endpoints across slider extremes.+/// Non-finite slider inputs must leave the directly evaluated samples unchanged.+#[test]+fn modern_brightness_contrast_curves_are_monotone_and_pin_endpoints() {+    for brightness in [-150.0, -125.0, -75.0, 0.0, 75.0, 125.0, 150.0] {+        for contrast in [-50.0, -17.0, 0.0, 100.0] {+            let table = adjust::modern_lut(brightness, contrast);+            assert_eq!(table.first(), Some(&0.0));+            assert_eq!(table.last(), Some(&1.0));+            assert!(table.windows(2).all(|p| p[0].is_finite() && p[0] <= p[1]));+        }+    }+    for x in [0.0, 0.25, 0.5, 0.75, 1.0] {+        assert_eq!(adjust::modern_brightness(x, f32::NAN), x);+        assert_eq!(adjust::modern_contrast(x, f32::INFINITY), x);+    }+}++/// Matches a clipped adjustment's rendered RGB/8 ramp to the Photoshop reference,+/// exercising document compositing in addition to direct buffer adjustment.+#[test]+fn clipped_brightness_contrast_render_matches_photoshop() {+    use photocraft_color::{ColorMode, PixelFormat, SampleType};+    use photocraft_doc::{Document, Layer, LayerContent, Size};+    use photocraft_raster::Surface;+    let mut doc = Document::new("clipped ramp", Size::new(256, 1), ColorMode::Rgb, SampleType::U8);+    let mut surface = Surface::new(PixelFormat::RGBA8);+    let bytes: Vec<u8> = (0..=255).flat_map(|x| [x, x, x, 255]).collect();+    surface.write_interleaved(doc.bounds(), &bytes);+    doc.layers.push(Layer::new("base", LayerContent::Raster(surface)));+    let mut adj = Layer::new("brightness", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness: 75.0, contrast: -17.0, legacy: false }));+    adj.clipped = true;+    doc.layers.push(adj);+    let actual = photocraft_compose::render(&doc, doc.bounds()).to_rgba8();+    for (pixel, expected) in actual.pixels.chunks_exact(4).zip(PHOTOSHOP[0].2) {+        assert_eq!(pixel, &[expected, expected, expected, 255]);+    }+}diff --git a/crates/compose/tests/stacked_adjustments.rs b/crates/compose/tests/stacked_adjustments.rsnew file mode 100644--- /dev/null+++ b/crates/compose/tests/stacked_adjustments.rs@@ -0,0 +1,91 @@+//! RGB/8 pixels measured from Photoshop 26.11.6 exports of the issue's clipped+//! brightness/contrast and hue/saturation stacks. The source pixel comes from+//! the same PSD's opaque image layer at (0, 0).++use photocraft_color::{ColorMode, PixelFormat, SampleType};+use photocraft_compose::render;+use photocraft_doc::adjust::HueRange;+use photocraft_doc::{Adjustment, Document, Layer, LayerContent, Size};+use photocraft_raster::Surface;++fn clipped_stack_on_pixel(source: [u8; 4], adjustments: impl IntoIterator<Item = Adjustment>) -> [u8; 4] {+    let mut doc = Document::new("stacked adjustments", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);+    let mut surface = Surface::new(PixelFormat::RGBA8);+    surface.write_interleaved(doc.bounds(), &source);+    doc.layers.push(Layer::new("image", LayerContent::Raster(surface)));+    for adjustment in adjustments {+        let mut layer = Layer::new("adjustment", LayerContent::Adjustment(adjustment));+        layer.clipped = true;+        doc.layers.push(layer);+    }+    let pixels = render(&doc, doc.bounds()).to_rgba8().pixels;+    [pixels[0], pixels[1], pixels[2], pixels[3]]+}++fn clipped_stack(adjustments: impl IntoIterator<Item = Adjustment>) -> [u8; 4] {+    clipped_stack_on_pixel([1, 34, 65, 255], adjustments)+}++#[test]+fn clipped_brightness_then_hue_saturation_matches_photoshop_rgb8() {+    let first = Adjustment::BrightnessContrast { brightness: -52.0, contrast: 43.0, legacy: false };+    let second = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() };+    assert_eq!(clipped_stack([first, second]), [0, 31, 15, 255]);+}++#[test]+fn clipped_hue_saturation_then_brightness_matches_photoshop_rgb8() {+    let first = Adjustment::HueSaturation { hue: -138.0, saturation: -42.0, lightness: 41.0, colorize: false, ranges: HueRange::defaults() };+    let second = Adjustment::BrightnessContrast { brightness: -54.0, contrast: 39.0, legacy: false };+    assert_eq!(clipped_stack([first, second]), [85, 88, 71, 255]);+}++#[test]+fn single_hue_saturation_from_second_stack_matches_photoshop_rgb8() {+    let adjustment = Adjustment::HueSaturation { hue: -138.0, saturation: -42.0, lightness: 41.0, colorize: false, ranges: HueRange::defaults() };+    assert_eq!(clipped_stack([adjustment]), [130, 134, 112, 255]);+}++#[test]+fn single_brightness_contrast_from_second_stack_matches_photoshop_rgb8() {+    let adjustment = Adjustment::BrightnessContrast { brightness: -54.0, contrast: 39.0, legacy: false };+    assert_eq!(clipped_stack([adjustment]), [1, 18, 38, 255]);+}++#[test]+fn single_hue_saturation_from_first_stack_matches_photoshop_rgb8() {+    let adjustment = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() };+    assert_eq!(clipped_stack([adjustment]), [0, 56, 25, 255]);+}++#[test]+fn hue_saturation_on_brightness_result_keeps_the_sector_edge_below_maximum() {+    let adjustment = Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() };+    // The first stack's BC layer produces this exact byte pixel. Photoshop's single-layer+    // probe returns 132 in red even though blue is the maximum channel at 133.+    assert_eq!(clipped_stack_on_pixel([154, 107, 105, 255], [adjustment]), [132, 85, 133, 255]);+}++/// Crossed Lightness signs and near-tie colours from a separate Photoshop RGB/8+/// probe keep the byte-path choice independent of either issue parameter tuple.+#[test]+fn hue_saturation_rgb8_crossed_lightness_and_sector_edges_match_photoshop() {+    let inputs = [[2, 38, 74], [1, 34, 65], [1, 19, 19], [27, 8, 27], [131, 130, 112], [43, 103, 104]];+    let oracles = [+        (-62.0, 15.0, 16.0, [[37, 107, 70], [36, 99, 64], [40, 57, 39], [45, 46, 64], [151, 132, 133], [72, 132, 71]]),+        (-138.0, -42.0, -41.0, [[30, 35, 10], [26, 30, 8], [9, 7, 3], [7, 13, 11], [72, 68, 75], [54, 48, 32]]),+        (-62.0, 15.0, -16.0, [[0, 64, 30], [0, 56, 25], [1, 17, 0], [5, 6, 24], [111, 92, 93], [32, 92, 31]]),+        (-138.0, -42.0, 41.0, [[134, 139, 114], [130, 134, 112], [113, 111, 107], [111, 117, 115], [176, 172, 179], [158, 152, 136]]),+    ];+    let mut mismatches = Vec::new();+    for (hue, saturation, lightness, expected) in oracles {+        let adjustment = Adjustment::HueSaturation { hue, saturation, lightness, colorize: false, ranges: HueRange::defaults() };+        for (input, want) in inputs.into_iter().zip(expected) {+            let got = clipped_stack_on_pixel([input[0], input[1], input[2], 255], [adjustment.clone()]);+            if got != [want[0], want[1], want[2], 255] {+                mismatches.push((hue, saturation, lightness, input, got, want));+            }+        }+    }+    assert!(mismatches.is_empty(), "{mismatches:?}");+}diff --git a/crates/gpu/src/compose.wgsl b/crates/gpu/src/compose.wgsl--- a/crates/gpu/src/compose.wgsl+++ b/crates/gpu/src/compose.wgsl@@ -635,6 +635,76 @@ fn tone_channel(row: i32, v: f32, identity: f32, extrapolate: f32) -> f32 {     return lut(row, v); } +/// RGB/8 master adjustment on whole channel and 1/256-sector hue levels for p1.y-selected+/// neutral-range hue -180..=0 degrees. Mirrors compose::adjust::hue_saturation_rgb8 stage rounding.+fn hue_saturation_rgb8(c: vec3<f32>, hue: f32, saturation: f32, lightness: f32) -> vec3<f32> {+    if (hue == 0.0 && saturation == 0.0 && lightness == 0.0) { return c; }+    var rgb = vec3<i32>(floor(clamp(c, vec3(0.0), vec3(1.0)) * 255.0 + vec3(0.5)));+    let light = sign(lightness) * floor(abs(lightness) * 255.0 + 1e-4) / 255.0;+    if (light >= 0.0) {+        let levels = vec3<f32>(rgb);+        rgb = vec3<i32>(floor(levels + (vec3(255.0) - levels) * light + vec3(0.5)));+    } else {+        rgb = vec3<i32>(floor(vec3<f32>(rgb) * (1.0 + light) + vec3(0.5)));+    }+    let minimum = min(rgb.r, min(rgb.g, rgb.b));+    let maximum = max(rgb.r, max(rgb.g, rgb.b));+    let old_chroma = maximum - minimum;+    if (old_chroma == 0) { return vec3(f32(minimum) / 255.0); }+    let sum = minimum + maximum;+    var factor = 0;+    if (saturation < 0.0) {+        factor = 256 - i32(floor(-saturation * 256.0 + 1e-4));+    } else {+        let steps = clamp(i32(floor(saturation * 254.0 + 1e-4)), 0, 254);+        factor = min(65280 / (255 - steps), 32767);+    }+    let chroma = min((old_chroma * factor + 127) / 256, min(sum, 510 - sum));+    let low = (sum - chroma) / 2;+    let high = (sum + chroma) / 2;+    var base = 0;+    var numerator = rgb.g - rgb.b;+    if (rgb.r > rgb.g && rgb.r >= rgb.b) {+        base = 0;+    } else if (rgb.g > rgb.b && rgb.g >= rgb.r) {+        base = 512;+        numerator = rgb.b - rgb.r;+    } else {+        base = 1024;+        numerator = rgb.r - rgb.g;+    }+    let ratio = sign(numerator) * ((abs(numerator) * 255 + old_chroma / 2) / old_chroma);+    var shift = 0;+    // Match compose's measured RGB/8 quarter-step slider phase and sector crossing.+    if (hue != 0.0) { shift = i32(floor(hue * 256.0 / 60.0 - 0.25)); }+    let primary = base + shift;+    let primary_sector = (((primary % 1536) + 1536) % 1536) / 256;+    let raw = base + ratio + shift;+    let initial = ((raw % 1536) + 1536) % 1536;+    let initial_sector = initial / 256;+    var phase = 0;+    if (hue != 0.0 && numerator > 0 && initial_sector != primary_sector && initial_sector % 2 == 0) { phase = 2; }+    let position = (initial + phase) % 1536;+    let sector = position / 256;+    let offset = position % 256;+    let rising = low + (chroma * offset + 127) / 256;+    var complement = 254;+    if (hue == 0.0 || (numerator < 0 && initial_sector != primary_sector)) { complement = 256; }+    let falling = low + i32(floor(f32(chroma * (complement - offset) + 127) / 256.0));+    var result = vec3<i32>(high, rising, low);+    switch sector {+        case 1: { result = vec3<i32>(falling, high, low); }+        case 2: { result = vec3<i32>(low, high, rising); }+        case 3: { result = vec3<i32>(low, falling, high); }+        case 4: { result = vec3<i32>(rising, low, high); }+        case 5: { result = vec3<i32>(high, low, falling); }+        default: {}+    }+    return vec3<f32>(clamp(result, vec3<i32>(0), vec3<i32>(255))) / 255.0;+}++/// Applies the packed adjustment kind to one encoded RGB pixel; kind 8 selects the bounded+/// whole-level RGB/8 Hue/Saturation kernel only when plan.rs sets p1.y, otherwise the float path. fn adjust(c: vec3<f32>) -> vec3<f32> {     let hdr = (op.flags & F_HDR) != 0u;     let p0 = op.p0;@@ -668,6 +738,7 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {         case 7: { return vec3(tone_channel(0, c.r, p0.x, p0.w), tone_channel(1, c.g, p0.y, p0.w), tone_channel(2, c.b, p0.z, p0.w)); } // Levels / Curves / modern Brightness/Contrast (compose::adjust::modern_lut)         case 17: { return vec3(levels_float(0, levels_float(1, c.r)), levels_float(0, levels_float(2, c.g)), levels_float(0, levels_float(3, c.b))); }         case 8: {                                                              // Hue/Saturation+            if (p1.y > 0.5) { return hue_saturation_rgb8(c, p0.x, p0.y, p0.z); }             let hsl = rgb_to_hsl(c);             var hh: f32;             var ss: f32;diff --git a/crates/gpu/src/plan/lower.rs b/crates/gpu/src/plan/lower.rs--- a/crates/gpu/src/plan/lower.rs+++ b/crates/gpu/src/plan/lower.rs@@ -115,7 +115,8 @@ fn transfer_exponent(t: Transfer) -> f32 {     } } -/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.+/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`;+/// Hue/Saturation sets p1.y for the bounded neutral-range RGB/8 integer shader path. pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraft_color::SampleType, mode: photocraft_color::ColorMode) -> Program {     let mut p = [[0.0f32; 4]; 4];     match adj {@@ -147,6 +148,11 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf             (4, p, None)         }         Adjustment::BrightnessContrast { brightness, contrast, .. } => {+            // The lookup table covers 0..1; identity must retain the full HDR range.+            if *brightness == 0.0 && *contrast == 0.0 {+                p[0] = [1.0, 1.0, 1.0, 0.0];+                return (7, p, None);+            }             // Modern B/C: the CPU's lookup table (compose::adjust::modern_lut) on every channel,             // through the Levels / Curves kernel.             let row = to_row(&adjust::modern_lut(*brightness, *contrast));@@ -183,6 +189,20 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer, depth: photocraf                 p[1][0] = 1.0;                 (8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))             } else {+                // compose::adjust uses whole RGB/8 channel and hue levels for the neutral-range+                // master adjustment. p1.y selects the matching shader path.+                if mode == photocraft_color::ColorMode::Rgb+                    && depth == photocraft_color::SampleType::U8+                    && !*colorize+                    && hue.is_finite()+                    && (-180.0..=0.0).contains(hue)+                    && saturation.is_finite()+                    && (-100.0..=100.0).contains(saturation)+                    && lightness.is_finite()+                    && (-100.0..=100.0).contains(lightness)+                {+                    p[1][1] = 1.0;+                }                 (8, p, None)             }         }diff --git a/crates/gpu/tests/it/parity.rs b/crates/gpu/tests/it/parity.rs--- a/crates/gpu/tests/it/parity.rs+++ b/crates/gpu/tests/it/parity.rs@@ -27,12 +27,15 @@ struct Gpu { /// Simulated texture limit (pages and chunks of 256 px). const PAGED_LIMIT: u32 = 256; +/// Compares HDR adjustments with the CPU reference through whole and paged textures.+/// Neutral modern Brightness/Contrast must retain above-white F32 values. #[test] fn hdr_adjustments_preserve_range_and_cpu_parity() {     let Some(mut g) = gpu() else { return };     let identity = vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];     let channel = LevelsChannel { in_black: 0.25, in_white: 0.75, gamma: 2.0, out_black: -0.1, out_white: 1.5 };     for adj in [+        Adjustment::BrightnessContrast { brightness: 0.0, contrast: 0.0, legacy: false },         Adjustment::Exposure { exposure: 0.0, offset: 0.0, gamma: 1.0 },         Adjustment::Exposure { exposure: 1.0, offset: 0.1, gamma: 1.3 },         Adjustment::Exposure { exposure: 0.8, offset: -0.02, gamma: 1.2 },@@ -227,6 +230,30 @@ fn check(g: &mut Gpu, doc: &Document, what: &str) {     diff_rect(g, doc, doc.bounds(), what).unwrap_or_else(|e| panic!("{e}")); } +/// Compares clipped modern Brightness/Contrast ramps with the CPU at U8, U16 and F32+/// depths, including partial alpha, through both whole and paged GPU textures.+#[test]+fn modern_brightness_contrast_clipped_ramp() {+    let Some(mut g) = gpu() else { return };+    for depth in [SampleType::U8, SampleType::U16, SampleType::F32] {+        for brightness in [-150.0, -75.0, 0.0, 75.0, 150.0] {+            let mut doc = Document::new("brightness ramp", Size::new(256, 2), ColorMode::Rgb, depth);+            let mut surface = photocraft_raster::Surface::new(doc.pixel_format());+            for x in 0..256 {+                let value = x as f32 / 255.0;+                for y in 0..2 {+                    surface.write_pixel(x, y, &[value, 1.0 - value, 0.25, if y == 0 { 1.0 } else { 0.4 }]);+                }+            }+            doc.layers.push(Layer::new("base", LayerContent::Raster(surface)));+            let mut adj = Layer::new("brightness", LayerContent::Adjustment(Adjustment::BrightnessContrast { brightness, contrast: -17.0, legacy: false }));+            adj.clipped = true;+            doc.layers.push(adj);+            check(&mut g, &doc, &format!("modern B/C {depth:?} brightness={brightness}"));+        }+    }+}+ /// Growing a layer's tile footprint keeps the old texels in the overlap (`keep_overlap`) /// while the same update rewrites tiles inside that overlap (#6243): whatever submission /// shape the copy takes, the kept overlap must never overwrite tiles the same update@@ -652,6 +679,86 @@ fn hue_ranges() -> [HueRange; 6] {     r } +/// The neutral-range RGB/8 GPU path reproduces eight Photoshop pixels from the issue source+/// and matches the CPU compositor after the document's 8-bit quantization.+#[test]+fn rgb8_hue_saturation_matches_cpu_and_photoshop_samples() {+    let Some(mut g) = gpu() else { return };+    let samples = [+        ([1, 34, 65], [109, 100, 46]),+        ([1, 83, 133], [169, 136, 39]),+        ([108, 159, 180], [202, 178, 130]),+        ([74, 141, 183], [207, 180, 99]),+        ([84, 138, 166], [192, 169, 110]),+        ([12, 19, 25], [72, 70, 60]),+        ([170, 184, 193], [207, 202, 185]),+        ([143, 178, 198], [214, 199, 159]),+    ];+    let mut doc = Document::new("RGB8 hue saturation", Size::new(samples.len() as u32, 1), ColorMode::Rgb, SampleType::U8);+    let mut raster = Layer::raster("source", PixelFormat::RGBA8);+    let pixels: Vec<f32> = samples.iter().flat_map(|(rgb, _)| rgb.iter().map(|&v| v as f32 / 255.0).chain(std::iter::once(1.0))).collect();+    raster.surface_mut().unwrap().write_region(doc.bounds(), &pixels);+    doc.layers.push(raster);+    doc.layers.push(Layer::new(+        "Hue/Saturation",+        LayerContent::Adjustment(Adjustment::HueSaturation { hue: -158.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() }),+    ));+    let cpu = photocraft_compose::flatten(&doc);+    let gpu = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();+    for (i, (_, expected)) in samples.iter().enumerate() {+        let cpu_rgb: [u8; 3] = std::array::from_fn(|channel| (cpu.px[i][channel] * 255.0).round() as u8);+        let gpu_rgb: [u8; 3] = std::array::from_fn(|channel| (gpu[i][channel] * 255.0).round() as u8);+        assert_eq!(cpu_rgb, *expected, "CPU sample {i}");+        assert_eq!(gpu_rgb, *expected, "GPU sample {i}");+    }+}++/// Hue zero places yellow and cyan on Photoshop's descending sector edge for positive+/// Saturation or Lightness, identically on the CPU and GPU.+#[test]+fn rgb8_hue_saturation_primary_ties_match_photoshop() {+    let Some(mut g) = gpu() else { return };+    for (saturation, lightness, expected) in [(20.0, 0.0, [[254, 255, 0], [0, 254, 255]]), (0.0, 20.0, [[254, 255, 51], [51, 254, 255]])] {+        let mut doc = Document::new("RGB8 primary ties", Size::new(2, 1), ColorMode::Rgb, SampleType::U8);+        let mut raster = Layer::raster("source", PixelFormat::RGBA8);+        raster.surface_mut().unwrap().write_region(doc.bounds(), &[1.0, 1.0, 0.0, 1.0, 0.0, 1.0, 1.0, 1.0]);+        doc.layers.push(raster);+        doc.layers.push(Layer::new(+            "Hue/Saturation",+            LayerContent::Adjustment(Adjustment::HueSaturation { hue: 0.0, saturation, lightness, colorize: false, ranges: HueRange::defaults() }),+        ));+        let cpu = photocraft_compose::flatten(&doc);+        let gpu = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();+        for (i, want) in expected.into_iter().enumerate() {+            let cpu_rgb: [u8; 3] = std::array::from_fn(|channel| (cpu.px[i][channel] * 255.0).round() as u8);+            let gpu_rgb: [u8; 3] = std::array::from_fn(|channel| (gpu[i][channel] * 255.0).round() as u8);+            assert_eq!(cpu_rgb, want, "CPU s{saturation} l{lightness}, sample {i}");+            assert_eq!(gpu_rgb, want, "GPU s{saturation} l{lightness}, sample {i}");+        }+    }+}++/// A positive hue uses the existing float shader path; the +40-degree red ramp pixel keeps+/// the same one-level-closer green channel result as the CPU compositor.+#[test]+fn rgb8_positive_hue_keeps_existing_float_shader_path() {+    let Some(mut g) = gpu() else { return };+    let mut doc = Document::new("positive hue fallback", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);+    let mut raster = Layer::raster("source", PixelFormat::RGBA8);+    raster.surface_mut().unwrap().write_region(doc.bounds(), &[1.0, 1.0 / 255.0, 0.0, 1.0]);+    doc.layers.push(raster);+    doc.layers.push(Layer::new(+        "Hue/Saturation",+        LayerContent::Adjustment(Adjustment::HueSaturation { hue: 40.0, saturation: 20.0, lightness: 20.0, colorize: false, ranges: HueRange::defaults() }),+    ));+    let cpu = photocraft_compose::flatten(&doc);+    let gpu = render_to_vec(&mut g.comp, &g.device, &g.queue, &doc, doc.bounds()).unwrap();+    let cpu_rgb: [u8; 3] = std::array::from_fn(|channel| (cpu.px[0][channel] * 255.0).round() as u8);+    let gpu_rgb: [u8; 3] = std::array::from_fn(|channel| (gpu[0][channel] * 255.0).round() as u8);+    assert_eq!(cpu_rgb, [255, 188, 51]);+    assert_eq!(gpu_rgb, cpu_rgb);+}+ fn adjustments() -> Vec<Adjustment> {     let lc = |a: f32, b: f32, g: f32| LevelsChannel { in_black: a, in_white: b, gamma: g, out_black: 0.05, out_white: 0.95 };     let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::<Vec<_>>();@@ -788,6 +895,54 @@ fn adjustment_layers() {     } } +#[test]+fn clipped_stacked_adjustments_match_photoshop_rgb8_bytes() {+    let Some(mut g) = gpu() else { return };+    let cases = [+        (+            [1, 34, 65, 255],+            [+                Adjustment::BrightnessContrast { brightness: -52.0, contrast: 43.0, legacy: false },+                Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() },+            ],+            [0, 31, 15, 255],+        ),+        (+            [194, 153, 151, 255],+            [+                Adjustment::BrightnessContrast { brightness: -52.0, contrast: 43.0, legacy: false },+                Adjustment::HueSaturation { hue: -62.0, saturation: 15.0, lightness: -16.0, colorize: false, ranges: HueRange::defaults() },+            ],+            [132, 85, 133, 255],+        ),+        (+            [1, 34, 65, 255],+            [+                Adjustment::HueSaturation { hue: -138.0, saturation: -42.0, lightness: 41.0, colorize: false, ranges: HueRange::defaults() },+                Adjustment::BrightnessContrast { brightness: -54.0, contrast: 39.0, legacy: false },+            ],+            [85, 88, 71, 255],+        ),+    ];+    for (source, adjustments, expected) in cases {+        let mut doc = Document::new("stacked RGB8", Size::new(1, 1), ColorMode::Rgb, SampleType::U8);+        let mut surface = photocraft_raster::Surface::new(PixelFormat::RGBA8);+        surface.write_interleaved(doc.bounds(), &source);+        doc.layers.push(Layer::new("image", LayerContent::Raster(surface)));+        for adjustment in adjustments {+            let mut layer = Layer::new("adjustment", LayerContent::Adjustment(adjustment));+            layer.clipped = true;+            doc.layers.push(layer);+        }+        assert_eq!(photocraft_compose::render(&doc, doc.bounds()).to_rgba8().pixels, expected);+        for comp in [&mut g.comp, &mut g.paged] {+            let pixels = render_to_vec(comp, &g.device, &g.queue, &doc, doc.bounds()).expect("render stacked adjustments");+            let actual: [u8; 4] = pixels[0].map(|channel| (channel.clamp(0.0, 1.0) * 255.0).round() as u8);+            assert_eq!(actual, expected, "GPU stacked RGB8 source {source:?}");+        }+    }+}+ #[test] fn groups_masks_and_clipping() {     let Some(mut g) = gpu() else { return };