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
@@ -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]@@ -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),@@ -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) to@@ -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 levelsnew file mode 100644@@ -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]);+ }+}new file mode 100644@@ -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:?}");+}@@ -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;@@ -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) } }@@ -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 };