Image › Adjustments overhaul: full params, one editor per kind, live-preview dialogs, Curves point deletion (#68)

* Adjustments: full parameter sets for every kind, Hue/Sat ranges, CMYK/Lab tone channels

- doc: Hue/Saturation gains six colour ranges (HueRange), Gradient Map a dither flag,
  Levels/Curves a ToneSpace (RGB, CMYK inks + black, Lab) for mode-native channels.
- engine: new adjust_params module (from_params over a base / to_params) used by
  image.adjustments.*, layer.newAdjustmentLayer.* and layer.setAdjustment. Curves,
  Color Balance, Channel Mixer, Black & White, Gradient Map and Photo Filter now honour
  their params; bad params are BadParams errors; setAdjustment merges (empty = reset).
- compose/gpu: hue-range tables, CMYK/Lab tone channels (CPU; GPU falls back), dithered
  Gradient Map, Black & White from grey/secondary/primary parts; GPU parity extended.
- io: hue2 ranges, grdm dither, CMYK/Lab Levels/Curves records round-trip.
- destructive adjustments use the document's tone transfer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Adjustments UI: one editor per kind for Properties and Image › Adjustments dialogs

- adjust_editors: Brightness/Contrast (legacy), Levels and Curves (per channel in RGB, Gray,
  CMYK and Lab, histogram behind), Exposure, Vibrance, Hue/Saturation (master + six ranges with
  draggable range sliders, colorize, before/after spectrum), Color Balance (tones, preserve
  luminosity), Black & White (six weights, tint colour), Photo Filter (presets or colour,
  density), Channel Mixer (outputs, constant, monochrome, total), Posterize, Threshold
  (histogram), Gradient Map (stops, presets, reverse, dither). Replaces the M7 placeholder.
- Properties previews live (GPU) while a control moves and commits one setAdjustment per gesture.
- adjust_dialog: Image › Adjustments opens the same editor modally; the canvas previews the
  real command on the proxy, Preview checkbox, OK = one history step, Cancel leaves the doc.
- Curves (#43): hit-test at the press origin, Delete/Backspace removes the selected point when
  the graph has focus, Cmd/Ctrl-click removes, dragging off removes (and back on restores),
  arrows nudge; endpoints and the last two points stay; pressing the gradient bars does nothing.
- tone: histograms per channel space, of the image below a layer or of the target layer alone.
- kittests: every adjustment layer kind edited through the Properties panel with real input at
  8/16/32-bit RGB, Gray, CMYK and Lab (#12); Curves point interactions; dialog preview/OK/Cancel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* io tests: adjustment round trip covers the new Levels/Curves/Hue-Sat/Gradient Map fields

#60's round-trip tests predate #68's doc-model additions (tone space and black channel,
Hue/Saturation ranges, Gradient Map dither); fill them with their defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 16:08:40 -04:00 committed by GitHub
parent 7a5c1930b3
commit 37c8f81032
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 3525 additions and 827 deletions

View File

@ -5,7 +5,7 @@
use photocraft_color::convert::rgb_to_gray; use photocraft_color::convert::rgb_to_gray;
use photocraft_doc::Adjustment; use photocraft_doc::Adjustment;
use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace};
use crate::Buffer; use crate::Buffer;
@ -96,39 +96,26 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
}) })
}) })
} }
Adjustment::Levels { master, per_channel } => { Adjustment::Levels { space, .. } | Adjustment::Curves { space, .. } => {
let luts: [Vec<f32>; 3] = let luts = tone_luts(adj);
std::array::from_fn(|i| (0..LUT_SIZE).map(|k| levels(&per_channel[i], levels(master, k as f32 / (LUT_SIZE - 1) as f32))).collect()); match space {
map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))) ToneSpace::Rgb => map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i]))),
ToneSpace::Cmyk | ToneSpace::Lab => map_rgb(buf, |c| tone_in_space(*space, &luts, c)),
}
} }
Adjustment::Curves { master, per_channel } => { Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
// Photoshop applies each channel's curve first, then the let table = (!*colorize && ranges.iter().any(|r| !r.is_neutral())).then(|| hue_range_tables(ranges));
// composite (RGB) curve.
let m = curve_lut(master);
let luts: [Vec<f32>; 3] = std::array::from_fn(|i| {
let ch = curve_lut(&per_channel[i]);
ch.iter().map(|&v| lut(&m, v)).collect()
});
map_rgb(buf, |c| std::array::from_fn(|i| lut(&luts[i], c[i])))
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
let (h, s, l) = (*hue, *saturation / 100.0, *lightness / 100.0);
map_rgb(buf, |c| { map_rgb(buf, |c| {
let (mut hh, mut ss, ll) = rgb_to_hsl(c); let (dh, ds, dl) = match &table {
if *colorize { Some(t) => {
hh = h.rem_euclid(360.0) / 360.0; // Range edits follow the pixel's original hue, faded out towards grey (greys have no hue).
ss = s.abs().max(0.25); let h0 = rgb_to_hsl(c).0;
} else { let chroma = ((c[0].max(c[1]).max(c[2]) - c[0].min(c[1]).min(c[2])) * 4.0).min(1.0);
hh = (hh + h / 360.0).rem_euclid(1.0); (lut(&t[0], h0) * chroma, lut(&t[1], h0) * chroma, lut(&t[2], h0) * chroma)
ss = (ss * (1.0 + s)).clamp(0.0, 1.0); }
} None => (0.0, 0.0, 0.0),
let mut rgb = hsl_to_rgb(hh, ss, ll); };
if l > 0.0 { hue_saturation(c, *hue + dh, (*saturation / 100.0 + ds).clamp(-1.0, 1.0), (*lightness / 100.0 + dl).clamp(-1.0, 1.0), *colorize)
rgb = rgb.map(|v| v + (1.0 - v) * l);
} else if l < 0.0 {
rgb = rgb.map(|v| v * (1.0 + l));
}
rgb
}) })
} }
Adjustment::Vibrance { vibrance, saturation } => { Adjustment::Vibrance { vibrance, saturation } => {
@ -154,28 +141,30 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
} }
}), }),
Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| { Adjustment::BlackWhite { weights, tint } => map_rgb(buf, |c| {
// weights: reds, yellows, greens, cyans, blues, magentas in percent (PS defaults 40,60,40,60,20,80) let g = black_white_gray(c, weights);
let (h, s, _l) = rgb_to_hsl(c);
let base = rgb_to_gray(c);
let sector = h * 6.0;
let i0 = sector.floor() as usize % 6;
let i1 = (i0 + 1) % 6;
let f = sector.fract();
let defaults = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
let w = (weights[i0] - defaults[i0]) * (1.0 - f) + (weights[i1] - defaults[i1]) * f;
let g = (base + s * w / 100.0 * 0.5).clamp(0.0, 1.0);
match tint { match tint {
Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5), Some(t) => std::array::from_fn(|i| (g * t[i] * 2.0).clamp(0.0, 1.0) * 0.5 + g * 0.5),
None => [g; 3], None => [g; 3],
} }
}), }),
Adjustment::GradientMap { stops, reverse } => map_rgb(buf, |c| { Adjustment::GradientMap { stops, reverse, dither } => {
let mut t = rgb_to_gray(c); let (w, x0, y0) = (buf.rect.width().max(1) as usize, buf.rect.x0, buf.rect.y0);
if *reverse { for (i, p) in buf.px.iter_mut().enumerate() {
t = 1.0 - t; if p[3] <= 0.0 {
continue;
}
let mut t = rgb_to_gray([p[0], p[1], p[2]]);
if *reverse {
t = 1.0 - t;
}
let mut o = gradient(stops, t);
if *dither {
let d = bayer4(x0 + (i % w) as i32, y0 + (i / w) as i32) / 255.0;
o = o.map(|v| (v + d).clamp(0.0, 1.0));
}
p[..3].copy_from_slice(&o);
} }
gradient(stops, t) }
}),
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => map_rgb(buf, |c| { Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => map_rgb(buf, |c| {
let l = rgb_to_gray(c); let l = rgb_to_gray(c);
let ws = (1.0 - l * 2.0).clamp(0.0, 1.0); let ws = (1.0 - l * 2.0).clamp(0.0, 1.0);
@ -209,6 +198,117 @@ pub fn apply_with(adj: &Adjustment, buf: &mut Buffer, transfer: Transfer) {
} }
} }
/// Per-channel LUTs of a Levels or Curves adjustment, each channel's record composed with the
/// master (Photoshop applies the channel curve first, then the composite). Four rows: the three
/// `per_channel` channels then black (identity unless the space is CMYK). In Lab there is no
/// composite record, so the master is ignored.
pub fn tone_luts(adj: &Adjustment) -> [Vec<f32>; 4] {
let x = |k: usize| k as f32 / (LUT_SIZE - 1) as f32;
match adj {
Adjustment::Levels { master, per_channel, space, black } => {
let ident = LevelsChannel::default();
let m = if *space == ToneSpace::Lab { &ident } else { master };
let row = |c: &LevelsChannel| (0..LUT_SIZE).map(|k| levels(c, levels(m, x(k)))).collect();
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &ident })]
}
Adjustment::Curves { master, per_channel, space, black } => {
let m = if *space == ToneSpace::Lab { curve_lut(&[]) } else { curve_lut(master) };
let row = |c: &[CurvePoint]| curve_lut(c).iter().map(|&v| lut(&m, v)).collect();
[row(&per_channel[0]), row(&per_channel[1]), row(&per_channel[2]), row(if *space == ToneSpace::Cmyk { black } else { &[] })]
}
_ => std::array::from_fn(|_| (0..LUT_SIZE).map(x).collect()),
}
}
/// Applies channel LUTs to a display-RGB colour in CMYK (ink brightness, 1 - ink) or Lab space,
/// through the conversions the document's surfaces use. The change is added as a difference of
/// two round trips, so channels a curve leaves alone (and out-of-gamut colours) stay exact.
fn tone_in_space(space: ToneSpace, luts: &[Vec<f32>; 4], c: [f32; 3]) -> [f32; 3] {
use photocraft_color::convert::{cmyk_to_rgb, lab_to_srgb, rgb_to_cmyk, srgb_to_lab};
let (before, after) = match space {
ToneSpace::Cmyk => {
let ink = rgb_to_cmyk(c);
let out: [f32; 4] = std::array::from_fn(|i| 1.0 - lut(&luts[i], 1.0 - ink[i]));
if out == ink {
return c;
}
(cmyk_to_rgb(ink), cmyk_to_rgb(out))
}
ToneSpace::Lab => {
let l = srgb_to_lab(c);
let n = [l[0] / 100.0, (l[1] + 128.0) / 255.0, (l[2] + 128.0) / 255.0];
let o: [f32; 3] = std::array::from_fn(|i| lut(&luts[i], n[i]));
if o == n {
return c;
}
let back = |v: [f32; 3]| lab_to_srgb([v[0] * 100.0, v[1] * 255.0 - 128.0, v[2] * 255.0 - 128.0]);
(back(n), back(o))
}
ToneSpace::Rgb => return std::array::from_fn(|i| lut(&luts[i], c[i])),
};
std::array::from_fn(|i| (c[i] + after[i] - before[i]).clamp(0.0, 1.0))
}
/// Hue/Saturation's range edits as three hue-indexed tables (hue shift in degrees, saturation and
/// lightness as fractions), sampled at hue `k / (len - 1)` turns. Ranges add up where they overlap.
pub fn hue_range_tables(ranges: &[HueRange; 6]) -> [Vec<f32>; 3] {
let at = |k: usize| {
let deg = k as f32 / (LUT_SIZE - 1) as f32 * 360.0;
ranges.iter().filter(|r| !r.is_neutral()).fold([0.0f32; 3], |acc, r| {
let w = r.weight(deg);
[acc[0] + w * r.hue, acc[1] + w * r.saturation / 100.0, acc[2] + w * r.lightness / 100.0]
})
};
let rows: Vec<[f32; 3]> = (0..LUT_SIZE).map(at).collect();
std::array::from_fn(|i| rows.iter().map(|r| r[i]).collect())
}
/// One Hue/Saturation evaluation: hue shift in degrees, saturation and lightness in -1..=1.
/// Colorize sets the hue and saturation instead of shifting them.
pub fn hue_saturation(c: [f32; 3], hue: f32, s: f32, l: f32, colorize: bool) -> [f32; 3] {
let (mut hh, mut ss, ll) = rgb_to_hsl(c);
if colorize {
hh = hue.rem_euclid(360.0) / 360.0;
ss = s.abs().max(0.25);
} else {
hh = (hh + hue / 360.0).rem_euclid(1.0);
ss = (ss * (1.0 + s)).clamp(0.0, 1.0);
}
let mut rgb = hsl_to_rgb(hh, ss, ll);
if l > 0.0 {
rgb = rgb.map(|v| v + (1.0 - v) * l);
} else if l < 0.0 {
rgb = rgb.map(|v| v * (1.0 + l));
}
rgb
}
/// Black & White grey value. Each colour is the sum of a grey part (its smallest channel), a
/// secondary-colour part (yellow, cyan or magenta: middle − smallest) and a primary part (red,
/// green or blue: largest − middle); the sliders (percent, Photoshop defaults 40 60 40 60 20 80 for
/// reds, yellows, greens, cyans, blues, magentas) weight the two colour parts.
pub fn black_white_gray(c: [f32; 3], weights: &[f32; 6]) -> f32 {
let (r, g, b) = (c[0], c[1], c[2]);
let (max, min) = (r.max(g).max(b), r.min(g).min(b));
let mid = r + g + b - max - min;
// Primary: the largest channel; secondary: the two largest channels together.
let primary = if r >= g && r >= b {
0
} else if g >= b {
2
} else {
4
};
let secondary = if b <= r && b <= g {
1 // red + green = yellow
} else if r <= g {
3 // green + blue = cyan
} else {
5 // red + blue = magenta
};
(min + (mid - min) * weights[secondary] / 100.0 + (max - mid) * weights[primary] / 100.0).clamp(0.0, 1.0)
}
/// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y). /// Ordered-dither offset in -0.5..0.5 (4×4 Bayer matrix) for document pixel (x, y).
pub fn bayer4(x: i32, y: i32) -> f32 { pub fn bayer4(x: i32, y: i32) -> f32 {
const M: [f32; 16] = [0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0]; const M: [f32; 16] = [0.0, 8.0, 2.0, 10.0, 12.0, 4.0, 14.0, 6.0, 3.0, 11.0, 1.0, 9.0, 15.0, 7.0, 13.0, 5.0];
@ -541,3 +641,93 @@ mod lookup_tests {
assert_eq!(bayer4(-4, -4), bayer4(0, 0)); assert_eq!(bayer4(-4, -4), bayer4(0, 0));
} }
} }
#[cfg(test)]
mod tone_tests {
use super::*;
use photocraft_geom::Rect;
fn ramp() -> Buffer {
let rect = Rect::new(0, 0, 8, 8);
let px = (0..64).map(|i| [((i % 8) as f32) / 7.0, ((i / 8) as f32) / 7.0, 0.3, 1.0]).collect();
Buffer { rect, px }
}
#[test]
fn identity_tone_in_every_space_is_identity() {
for space in [ToneSpace::Rgb, ToneSpace::Cmyk, ToneSpace::Lab] {
let mut a = Adjustment::identity_curves();
if let Adjustment::Curves { space: s, .. } = &mut a {
*s = space;
}
let mut b = ramp();
apply(&a, &mut b);
for (x, y) in b.px.iter().zip(ramp().px.iter()) {
for k in 0..3 {
assert!((x[k] - y[k]).abs() < 0.02, "{space:?} {x:?} {y:?}");
}
}
}
}
#[test]
fn cmyk_black_curve_darkens_and_lab_lightness_lifts() {
let line = |a: f32, b: f32| vec![CurvePoint { input: 0.0, output: a }, CurvePoint { input: 1.0, output: b }];
// Black brightness 1 -> 0.5: more black ink everywhere.
let a = Adjustment::Curves {
master: line(0.0, 1.0),
per_channel: [line(0.0, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
space: ToneSpace::Cmyk,
black: line(0.0, 0.5),
};
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.8, 0.8, 0.8, 1.0]] };
apply(&a, &mut b);
assert!(b.px[0][0] < 0.7, "{:?}", b.px[0]);
// Lab lightness 0..1 -> 0.2..1 lifts a dark grey; a/b untouched keeps it grey.
let a = Adjustment::Curves {
master: line(1.0, 0.0), // ignored in Lab
per_channel: [line(0.2, 1.0), line(0.0, 1.0), line(0.0, 1.0)],
space: ToneSpace::Lab,
black: Vec::new(),
};
let mut b = Buffer { rect: Rect::new(0, 0, 1, 1), px: vec![[0.2, 0.2, 0.2, 1.0]] };
apply(&a, &mut b);
let p = b.px[0];
assert!(p[0] > 0.3 && (p[0] - p[2]).abs() < 0.02, "{p:?}");
}
#[test]
fn hue_ranges_only_touch_their_colours() {
let mut ranges = HueRange::defaults();
ranges[0].saturation = -100.0; // desaturate reds
let a = Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges };
let mut b = Buffer { rect: Rect::new(0, 0, 3, 1), px: vec![[0.9, 0.1, 0.1, 1.0], [0.1, 0.1, 0.9, 1.0], [0.5, 0.5, 0.5, 1.0]] };
apply(&a, &mut b);
let red = b.px[0];
assert!((red[0] - red[1]).abs() < 0.05, "red desaturated {red:?}");
assert!((b.px[1][2] - 0.9).abs() < 1e-4 && (b.px[1][0] - 0.1).abs() < 1e-4, "blue untouched {:?}", b.px[1]);
assert_eq!(b.px[2], [0.5, 0.5, 0.5, 1.0]);
}
#[test]
fn black_white_weights() {
let d = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
assert!((black_white_gray([0.5; 3], &d) - 0.5).abs() < 1e-6, "greys keep their value");
assert!((black_white_gray([1.0, 0.0, 0.0], &d) - 0.4).abs() < 1e-6);
assert!((black_white_gray([1.0, 1.0, 0.0], &d) - 0.6).abs() < 1e-6);
let mut w = d;
w[0] = 100.0;
assert!(black_white_gray([1.0, 0.0, 0.0], &w) > black_white_gray([1.0, 0.0, 0.0], &d));
}
#[test]
fn gradient_map_dither_stays_close() {
let stops = vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])];
let mut a = ramp();
let mut b = ramp();
apply(&Adjustment::GradientMap { stops: stops.clone(), reverse: false, dither: false }, &mut a);
apply(&Adjustment::GradientMap { stops, reverse: false, dither: true }, &mut b);
assert!(a.px.iter().zip(&b.px).all(|(x, y)| (x[0] - y[0]).abs() <= 0.5 / 255.0 + 1e-6));
assert!(a.px.iter().zip(&b.px).any(|(x, y)| x[0] != y[0]));
}
}

View File

@ -246,11 +246,17 @@ fn hue_saturation_roundtrips_and_desaturates() {
assert!((back[i] - c[i]).abs() < 1e-5); assert!((back[i] - c[i]).abs() < 1e-5);
} }
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.8, 0.3, 0.1, 1.0]); let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [0.8, 0.3, 0.1, 1.0]);
adjust::apply(&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &mut buf); adjust::apply(
&Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
&mut buf,
);
let p = buf.px[0]; let p = buf.px[0];
assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5); assert!((p[0] - p[1]).abs() < 1e-5 && (p[1] - p[2]).abs() < 1e-5);
let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]); let mut buf = Buffer::filled(Rect::new(0, 0, 1, 1), [1.0, 0.0, 0.0, 1.0]);
adjust::apply(&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false }, &mut buf); adjust::apply(
&Adjustment::HueSaturation { hue: 120.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
&mut buf,
);
assert!(close4(buf.px[0], [0.0, 1.0, 0.0, 1.0])); assert!(close4(buf.px[0], [0.0, 1.0, 0.0, 1.0]));
} }

View File

@ -9,6 +9,14 @@ pub struct CurvePoint {
pub output: f32, pub output: f32,
} }
/// Whether `points` is the straight 0→0, 1→1 line (or too short to be a curve).
pub fn is_identity_curve(points: &[CurvePoint]) -> bool {
match points {
[a, b] => a.input == 0.0 && a.output == 0.0 && b.input == 1.0 && b.output == 1.0,
p => p.len() < 2,
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LevelsChannel { pub struct LevelsChannel {
pub in_black: f32, pub in_black: f32,
@ -24,6 +32,93 @@ impl Default for LevelsChannel {
} }
} }
/// Which document channels the per-channel records of Levels and Curves address. Compositing
/// happens in display RGB; `Cmyk` and `Lab` records are applied to the pixel converted into that
/// space (the same conversions the document's surfaces use), so ink and Lab channel edits behave
/// like Photoshop's in CMYK and Lab documents. Grayscale documents use `Rgb` with the master record.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToneSpace {
/// `per_channel` = red, green, blue.
#[default]
Rgb,
/// `per_channel` = cyan, magenta, yellow plus `black`; values are channel brightness
/// (1 - ink), and the master record applies to all four inks.
Cmyk,
/// `per_channel` = lightness, a, b (stored 0..=1 like Lab surfaces); there is no composite
/// record in Lab, so the master is ignored.
Lab,
}
/// One of Hue/Saturation's six colour ranges (reds, yellows, greens, cyans, blues, magentas).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HueRange {
/// Hue shift in degrees (-180..=180).
pub hue: f32,
/// Saturation change in percent (-100..=100).
pub saturation: f32,
/// Lightness change in percent (-100..=100).
pub lightness: f32,
/// Hue degrees where the range starts fading in, is fully on, starts fading out and ends
/// (Photoshop's four range sliders). May wrap past 0/360.
pub bounds: [f32; 4],
}
impl HueRange {
/// The neutral range `i` (0 = reds … 5 = magentas) with Photoshop's default extent: fully on
/// for 30° around its centre, fading over 30° each side.
pub fn neutral(i: usize) -> Self {
let c = 60.0 * i as f32;
Self { hue: 0.0, saturation: 0.0, lightness: 0.0, bounds: [c - 45.0, c - 15.0, c + 15.0, c + 45.0] }
}
pub fn defaults() -> [HueRange; 6] {
std::array::from_fn(Self::neutral)
}
/// The canonical form of four range sliders: increasing, within one turn, centre in
/// -30..330 (so the default reds are -45, -15, 15, 45 whichever way they were written).
pub fn canonical_bounds(b: [f32; 4]) -> [f32; 4] {
if b.iter().any(|v| !v.is_finite()) {
return [-45.0, -15.0, 15.0, 45.0];
}
let mut out = [b[0]; 4];
for k in 1..4 {
out[k] = (out[k - 1] + (b[k] - out[k - 1]).rem_euclid(360.0)).min(out[0] + 359.0);
}
let centre = (out[1] + out[2]) / 2.0;
let shift = (centre + 30.0).rem_euclid(360.0) - 30.0 - centre;
out.map(|v| v + shift)
}
pub fn is_neutral(&self) -> bool {
self.hue == 0.0 && self.saturation == 0.0 && self.lightness == 0.0
}
/// How much a pixel of hue `deg` (degrees) belongs to this range, 0..=1.
pub fn weight(&self, deg: f32) -> f32 {
let [a, b, c, d] = self.bounds;
// Measure from the range's start so wrap-around ranges (reds) work.
let rel = |x: f32| if x.is_finite() { (x - a).rem_euclid(360.0) } else { 0.0 };
let b = rel(b);
let c = rel(c).max(b);
let d = rel(d).max(c);
let h = rel(deg);
if h <= b {
if b <= 0.0 { 1.0 } else { h / b }
} else if h <= c {
1.0
} else if h <= d {
if d <= c { 1.0 } else { (d - h) / (d - c) }
} else {
0.0
}
}
}
fn default_hue_ranges() -> [HueRange; 6] {
HueRange::defaults()
}
/// Photoshop adjustment layers (Layer → New Adjustment Layer). /// Photoshop adjustment layers (Layer → New Adjustment Layer).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum Adjustment { pub enum Adjustment {
@ -32,15 +127,25 @@ pub enum Adjustment {
contrast: f32, contrast: f32,
legacy: bool, legacy: bool,
}, },
/// Composite channel first, then R, G, B. /// Composite channel first, then R, G, B (or the channels `space` names).
Levels { Levels {
master: LevelsChannel, master: LevelsChannel,
per_channel: [LevelsChannel; 3], per_channel: [LevelsChannel; 3],
#[serde(default)]
space: ToneSpace,
/// The black ink channel when `space` is [`ToneSpace::Cmyk`].
#[serde(default)]
black: LevelsChannel,
}, },
/// Master curve then R, G, B curves. /// Master curve then R, G, B curves (or the channels `space` names).
Curves { Curves {
master: Vec<CurvePoint>, master: Vec<CurvePoint>,
per_channel: [Vec<CurvePoint>; 3], per_channel: [Vec<CurvePoint>; 3],
#[serde(default)]
space: ToneSpace,
/// The black ink curve when `space` is [`ToneSpace::Cmyk`] (empty = identity).
#[serde(default)]
black: Vec<CurvePoint>,
}, },
Exposure { Exposure {
exposure: f32, exposure: f32,
@ -51,11 +156,14 @@ pub enum Adjustment {
vibrance: f32, vibrance: f32,
saturation: f32, saturation: f32,
}, },
/// Master hue / saturation / lightness (or the Colorize colour) plus six hue ranges.
HueSaturation { HueSaturation {
hue: f32, hue: f32,
saturation: f32, saturation: f32,
lightness: f32, lightness: f32,
colorize: bool, colorize: bool,
#[serde(default = "default_hue_ranges")]
ranges: [HueRange; 6],
}, },
ColorBalance { ColorBalance {
shadows: [f32; 3], shadows: [f32; 3],
@ -99,6 +207,9 @@ pub enum Adjustment {
GradientMap { GradientMap {
stops: Vec<(f32, [f32; 3])>, stops: Vec<(f32, [f32; 3])>,
reverse: bool, reverse: bool,
/// Ordered dither of ±½ an 8-bit step to hide banding.
#[serde(default)]
dither: bool,
}, },
/// Per range (reds, yellows, greens, cyans, blues, magentas, whites, neutrals, blacks) the /// Per range (reds, yellows, greens, cyans, blues, magentas, whites, neutrals, blacks) the
/// cyan, magenta, yellow and black change in percent (-100..=100), as in Photoshop. /// cyan, magenta, yellow and black change in percent (-100..=100), as in Photoshop.
@ -137,15 +248,49 @@ impl Adjustment {
} }
pub fn default_hue_saturation() -> Self { pub fn default_hue_saturation() -> Self {
Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false } Adjustment::HueSaturation { hue: 0.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }
} }
pub fn identity_curves() -> Self { pub fn identity_curves() -> Self {
let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
Adjustment::Curves { master: line(), per_channel: [line(), line(), line()] } Adjustment::Curves { master: line(), per_channel: [line(), line(), line()], space: ToneSpace::Rgb, black: Vec::new() }
} }
pub fn identity_levels() -> Self { pub fn identity_levels() -> Self {
Adjustment::Levels { master: LevelsChannel::default(), per_channel: Default::default() } Adjustment::Levels { master: LevelsChannel::default(), per_channel: Default::default(), space: ToneSpace::Rgb, black: LevelsChannel::default() }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hue_range_weights() {
let reds = HueRange::neutral(0);
assert_eq!(reds.weight(0.0), 1.0);
assert_eq!(reds.weight(350.0), 1.0);
assert!((reds.weight(30.0) - 0.5).abs() < 1e-5);
assert!((reds.weight(330.0) - 0.5).abs() < 1e-5);
assert_eq!(reds.weight(60.0), 0.0);
assert_eq!(reds.weight(180.0), 0.0);
let blues = HueRange::neutral(4);
assert_eq!(blues.weight(240.0), 1.0);
assert_eq!(blues.weight(120.0), 0.0);
// Degenerate bounds never divide by zero.
let odd = HueRange { bounds: [10.0, 10.0, 10.0, 10.0], ..HueRange::neutral(0) };
assert!(odd.weight(10.0).is_finite() && odd.weight(200.0).is_finite());
let nan = HueRange { bounds: [f32::NAN; 4], ..HueRange::neutral(0) };
assert!(nan.weight(10.0).is_finite());
}
#[test]
fn old_files_get_neutral_new_fields() {
let a: Adjustment = serde_json::from_str(r#"{"HueSaturation":{"hue":1.0,"saturation":2.0,"lightness":3.0,"colorize":false}}"#).unwrap();
assert!(matches!(a, Adjustment::HueSaturation { ranges, .. } if ranges == HueRange::defaults()));
let c: Adjustment = serde_json::from_str(r#"{"Curves":{"master":[],"per_channel":[[],[],[]]}}"#).unwrap();
assert!(matches!(c, Adjustment::Curves { space: ToneSpace::Rgb, .. }));
let g: Adjustment = serde_json::from_str(r#"{"GradientMap":{"stops":[],"reverse":true}}"#).unwrap();
assert!(matches!(g, Adjustment::GradientMap { dither: false, .. }));
} }
} }

View File

@ -92,7 +92,7 @@ impl Duotone {
let t = i as f32 / 32.0; let t = i as f32 / 32.0;
(t, self.render(t)) (t, self.render(t))
}); });
crate::Adjustment::GradientMap { stops: stops.collect(), reverse: false } crate::Adjustment::GradientMap { stops: stops.collect(), reverse: false, dither: false }
} }
} }

View File

@ -0,0 +1,792 @@
//! Full parameter sets for the per-pixel adjustment kinds, shared by the destructive
//! `image.adjustments.*` commands, `layer.newAdjustmentLayer.*` and `layer.setAdjustment`.
//!
//! [`from_params`] reads params over a base adjustment (missing keys keep the base's value, so a
//! partial update only changes what it names) and [`to_params`] writes the complete set back, so
//! `from_params(kind, &to_params(a), None, mode) == a`. Values out of range are clamped like
//! Photoshop's fields; a key of the wrong type is an error.
//!
//! Units: Levels and Curves in 0–255 levels; percentages for the rest (as in Photoshop's dialogs);
//! colours as `"#rrggbb"` or `[r, g, b]` in 0–1.
use photocraft_color::ColorMode;
use photocraft_doc::Adjustment;
use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace};
use serde_json::{Map, Value, json};
use crate::{EngineError, Result};
/// Hue/Saturation and Black & White colour-range keys (storage order).
pub const HUE_RANGES: [&str; 6] = ["reds", "yellows", "greens", "cyans", "blues", "magentas"];
/// Black & White default weights (percent) for [`HUE_RANGES`].
pub const BW_DEFAULTS: [f32; 6] = [40.0, 60.0, 40.0, 60.0, 20.0, 80.0];
/// Photo Filter presets `(id, label, colour)`: common photographic filter colours.
pub const PHOTO_FILTERS: &[(&str, &str, [u8; 3])] = &[
("warming85", "Warming Filter (85)", [236, 138, 0]),
("warmingLBA", "Warming Filter (LBA)", [250, 150, 0]),
("warming81", "Warming Filter (81)", [235, 177, 19]),
("cooling80", "Cooling Filter (80)", [0, 109, 255]),
("coolingLBB", "Cooling Filter (LBB)", [0, 93, 255]),
("cooling82", "Cooling Filter (82)", [0, 181, 255]),
("red", "Red", [234, 26, 26]),
("orange", "Orange", [243, 132, 23]),
("yellow", "Yellow", [249, 227, 28]),
("green", "Green", [25, 201, 25]),
("cyan", "Cyan", [29, 203, 234]),
("blue", "Blue", [29, 53, 234]),
("violet", "Violet", [155, 29, 234]),
("magenta", "Magenta", [227, 24, 227]),
("sepia", "Sepia", [172, 122, 51]),
("deepRed", "Deep Red", [255, 0, 0]),
("deepBlue", "Deep Blue", [0, 34, 205]),
("deepEmerald", "Deep Emerald", [0, 140, 0]),
("deepYellow", "Deep Yellow", [255, 213, 0]),
("underwater", "Underwater", [0, 194, 177]),
];
/// Per-channel keys of Levels/Curves in each tone space (after the composite).
pub fn channel_keys(space: ToneSpace) -> &'static [&'static str] {
match space {
ToneSpace::Rgb => &["red", "green", "blue"],
ToneSpace::Cmyk => &["cyan", "magenta", "yellow", "black"],
ToneSpace::Lab => &["lightness", "a", "b"],
}
}
/// The tone space new Levels/Curves get in a document of `mode`.
pub fn default_space(mode: ColorMode) -> ToneSpace {
match mode {
ColorMode::Cmyk => ToneSpace::Cmyk,
ColorMode::Lab => ToneSpace::Lab,
_ => ToneSpace::Rgb,
}
}
/// The neutral adjustment of `kind` (what "Reset to defaults" restores).
pub fn default_for(kind: &str, mode: ColorMode) -> Result<Adjustment> {
from_params(kind, &json!({}), None, mode)
}
/// Reads the adjustment of `kind` from `p` over `base` (or the kind's defaults when `base` is
/// None or of another kind). `mode` picks the Levels/Curves channel space when neither the params
/// nor the base name one.
pub fn from_params(kind: &str, p: &Value, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
let cmd = format!("adjustment `{kind}`");
if !p.is_object() && !p.is_null() {
return Err(bad(&cmd, "params must be an object"));
}
let r = Reader { cmd: &cmd, p };
Ok(match kind {
"invert" => Adjustment::Invert,
"brightnessContrast" => {
let (b0, c0, l0) = match base {
Some(Adjustment::BrightnessContrast { brightness, contrast, legacy }) => (*brightness, *contrast, *legacy),
_ => (0.0, 0.0, false),
};
let legacy = r.boolean("legacy", l0)?;
let (lo, hi) = if legacy { (-100.0, 100.0) } else { (-50.0, 100.0) };
Adjustment::BrightnessContrast { brightness: r.num("brightness", b0, -150.0, 150.0)?, contrast: r.num("contrast", c0, lo, hi)?, legacy }
}
"threshold" => {
let l0 = match base {
Some(Adjustment::Threshold { level }) => level * 255.0,
_ => 128.0,
};
Adjustment::Threshold { level: r.num("level", l0, 1.0, 255.0)?.round() / 255.0 }
}
"posterize" => {
let l0 = match base {
Some(Adjustment::Posterize { levels }) => *levels as f32,
_ => 4.0,
};
Adjustment::Posterize { levels: r.num("levels", l0, 2.0, 255.0)?.round() as u32 }
}
"exposure" => {
let (e, o, g) = match base {
Some(Adjustment::Exposure { exposure, offset, gamma }) => (*exposure, *offset, *gamma),
_ => (0.0, 0.0, 1.0),
};
Adjustment::Exposure { exposure: r.num("exposure", e, -20.0, 20.0)?, offset: r.num("offset", o, -0.5, 0.5)?, gamma: r.num("gamma", g, 0.01, 9.99)? }
}
"vibrance" => {
let (v, s) = match base {
Some(Adjustment::Vibrance { vibrance, saturation }) => (*vibrance, *saturation),
_ => (0.0, 0.0),
};
Adjustment::Vibrance { vibrance: r.num("vibrance", v, -100.0, 100.0)?, saturation: r.num("saturation", s, -100.0, 100.0)? }
}
"hueSaturation" => hue_saturation(&r, base)?,
"levels" => levels(&r, base, mode)?,
"curves" => curves(&r, base, mode)?,
"colorBalance" => {
let (mut tones, preserve) = match base {
Some(Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity }) => {
([*shadows, *midtones, *highlights], *preserve_luminosity)
}
_ => ([[0.0; 3]; 3], true),
};
for (i, key) in ["shadows", "midtones", "highlights"].iter().enumerate() {
if let Some(v) = r.numbers(key, 3)? {
tones[i] = [v[0], v[1], v[2]].map(|x| x.clamp(-100.0, 100.0));
}
}
Adjustment::ColorBalance {
shadows: tones[0],
midtones: tones[1],
highlights: tones[2],
preserve_luminosity: r.boolean("preserveLuminosity", preserve)?,
}
}
"blackWhite" => {
let (mut weights, tint0) = match base {
Some(Adjustment::BlackWhite { weights, tint }) => (*weights, *tint),
_ => (BW_DEFAULTS, None),
};
for (i, key) in HUE_RANGES.iter().enumerate() {
weights[i] = r.num(key, weights[i], -200.0, 300.0)?;
}
// Photoshop's default tint colour is a light sepia.
let color = r.color("tintColor", tint0.unwrap_or([0.882, 0.827, 0.702]))?;
let tint = r.boolean("tint", tint0.is_some())?.then_some(color);
Adjustment::BlackWhite { weights, tint }
}
"photoFilter" => {
let (c0, d0, p0) = match base {
Some(Adjustment::PhotoFilter { color, density, preserve_luminosity }) => (*color, *density, *preserve_luminosity),
_ => ([236.0 / 255.0, 138.0 / 255.0, 0.0], 0.25, true),
};
let preset = match p.get("filter") {
None | Some(Value::Null) => None,
Some(Value::String(id)) => {
let f = PHOTO_FILTERS.iter().find(|f| f.0 == id).ok_or_else(|| bad(&cmd, format!("unknown filter `{id}`")))?;
Some(f.2.map(|v| f32::from(v) / 255.0))
}
Some(_) => return Err(bad(&cmd, "`filter` must be a preset id")),
};
let color = r.color("color", preset.unwrap_or(c0))?;
Adjustment::PhotoFilter {
color,
density: r.num("density", d0 * 100.0, 0.0, 100.0)? / 100.0,
preserve_luminosity: r.boolean("preserveLuminosity", p0)?,
}
}
"channelMixer" => {
let (mut matrix, mono0) = match base {
Some(Adjustment::ChannelMixer { matrix, monochrome }) => (*matrix, *monochrome),
_ => ([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], false),
};
let monochrome = r.boolean("monochrome", mono0)?;
if monochrome && !mono0 && p.get("gray").is_none() {
// Turning Monochrome on starts from Photoshop's grey mix.
matrix[0] = [0.4, 0.4, 0.2, 0.0];
}
for (i, key) in ["red", "green", "blue"].iter().enumerate() {
if let Some(v) = r.numbers(key, 4)? {
matrix[i] = mixer_row(&v);
}
}
if let Some(v) = r.numbers("gray", 4)? {
matrix[0] = mixer_row(&v);
}
Adjustment::ChannelMixer { matrix, monochrome }
}
"gradientMap" => {
let (stops0, rev0, dither0) = match base {
Some(Adjustment::GradientMap { stops, reverse, dither }) => (stops.clone(), *reverse, *dither),
_ => (vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false),
};
let stops = match p.get("stops") {
None | Some(Value::Null) => stops0,
Some(v) => gradient_stops(&cmd, v)?,
};
Adjustment::GradientMap { stops, reverse: r.boolean("reverse", rev0)?, dither: r.boolean("dither", dither0)? }
}
"selectiveColor" => crate::adjust_cmds::selective_from_params(p, base.filter(|b| matches!(b, Adjustment::SelectiveColor { .. }))),
"colorLookup" => crate::adjust_cmds::lookup_from_params(p, base.filter(|b| matches!(b, Adjustment::ColorLookup { .. })))?,
other => return Err(bad("adjustment", format!("unknown adjustment kind `{other}`"))),
})
}
/// The complete parameter set of `adj` (the inverse of [`from_params`]).
pub fn to_params(adj: &Adjustment) -> Value {
match adj {
Adjustment::Invert | Adjustment::Unsupported { .. } => json!({}),
Adjustment::BrightnessContrast { brightness, contrast, legacy } => json!({"brightness": brightness, "contrast": contrast, "legacy": legacy}),
Adjustment::Threshold { level } => json!({"level": (level * 255.0).round()}),
Adjustment::Posterize { levels } => json!({"levels": levels}),
Adjustment::Exposure { exposure, offset, gamma } => json!({"exposure": exposure, "offset": offset, "gamma": gamma}),
Adjustment::Vibrance { vibrance, saturation } => json!({"vibrance": vibrance, "saturation": saturation}),
Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
let mut v = json!({"hue": hue, "saturation": saturation, "lightness": lightness, "colorize": colorize});
for (key, r) in HUE_RANGES.iter().zip(ranges.iter()) {
v[*key] = json!({"hue": r.hue, "saturation": r.saturation, "lightness": r.lightness, "range": r.bounds});
}
v
}
Adjustment::Levels { master, per_channel, space, black } => {
let mut v = levels_obj(master);
for (key, c) in channel_keys(*space).iter().zip([&per_channel[0], &per_channel[1], &per_channel[2], black]) {
v[*key] = levels_obj(c);
}
if *space == ToneSpace::Lab {
// No composite in Lab: the top-level keys are the lightness channel.
v = merge(levels_obj(&per_channel[0]), v);
}
v
}
Adjustment::Curves { master, per_channel, space, black } => {
let mut v = json!({"points": curve_arr(master)});
let ink = if black.len() >= 2 { black.clone() } else { identity_curve() };
let chans = per_channel.iter().cloned().chain(std::iter::once(ink));
for (key, c) in channel_keys(*space).iter().zip(chans) {
v[*key] = curve_arr(&c);
}
if *space == ToneSpace::Lab {
v["points"] = curve_arr(&per_channel[0]);
}
v
}
Adjustment::ColorBalance { shadows, midtones, highlights, preserve_luminosity } => {
json!({"shadows": shadows, "midtones": midtones, "highlights": highlights, "preserveLuminosity": preserve_luminosity})
}
Adjustment::BlackWhite { weights, tint } => {
let mut v = json!({"tint": tint.is_some(), "tintColor": hex(tint.unwrap_or([0.882, 0.827, 0.702]))});
for (key, w) in HUE_RANGES.iter().zip(weights.iter()) {
v[*key] = json!(w);
}
v
}
Adjustment::PhotoFilter { color, density, preserve_luminosity } => {
json!({"color": hex(*color), "density": (density * 1000.0).round() / 10.0, "preserveLuminosity": preserve_luminosity})
}
Adjustment::ChannelMixer { matrix, monochrome } => {
let row = |r: &[f32; 4]| json!(r.map(|x| (x * 1000.0).round() / 10.0));
let mut v = json!({"red": row(&matrix[0]), "green": row(&matrix[1]), "blue": row(&matrix[2]), "monochrome": monochrome});
if *monochrome {
v["gray"] = row(&matrix[0]);
}
v
}
Adjustment::GradientMap { stops, reverse, dither } => {
json!({"stops": stops.iter().map(|(t, c)| json!([t, hex(*c)])).collect::<Vec<_>>(), "reverse": reverse, "dither": dither})
}
Adjustment::SelectiveColor { relative, adjustments } => {
let mut v = json!({"method": if *relative { "relative" } else { "absolute" }});
for (i, key) in crate::adjust_cmds::RANGES.iter().enumerate() {
v[*key] = json!(adjustments[i]);
}
v
}
Adjustment::ColorLookup { tetrahedral, dither, .. } => {
json!({"interpolation": if *tetrahedral { "tetrahedral" } else { "trilinear" }, "dither": dither})
}
}
}
// ---------------------------------------------------------------------------------------------
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
}
/// Typed, clamped access to optional params.
struct Reader<'a> {
cmd: &'a str,
p: &'a Value,
}
impl Reader<'_> {
fn get(&self, key: &str) -> Option<&Value> {
self.p.get(key).filter(|v| !v.is_null())
}
fn num(&self, key: &str, default: f32, lo: f32, hi: f32) -> Result<f32> {
match self.get(key) {
None => Ok(default.clamp(lo, hi)),
Some(v) => num_in(self.cmd, key, v, lo, hi),
}
}
fn boolean(&self, key: &str, default: bool) -> Result<bool> {
match self.get(key) {
None => Ok(default),
Some(Value::Bool(b)) => Ok(*b),
Some(_) => Err(bad(self.cmd, format!("`{key}` must be true or false"))),
}
}
/// An array of exactly `n` numbers.
fn numbers(&self, key: &str, n: usize) -> Result<Option<Vec<f32>>> {
let Some(v) = self.get(key) else { return Ok(None) };
let a = v.as_array().filter(|a| a.len() == n).ok_or_else(|| bad(self.cmd, format!("`{key}` must be an array of {n} numbers")))?;
a.iter().map(|x| num_in(self.cmd, key, x, -1e6, 1e6)).collect::<Result<Vec<_>>>().map(Some)
}
fn color(&self, key: &str, default: [f32; 3]) -> Result<[f32; 3]> {
match self.get(key) {
None => Ok(default),
Some(v) => color_of(self.cmd, key, v),
}
}
/// A channel object (`{"inBlack": …}`) or None.
fn object(&self, key: &str) -> Result<Option<Reader<'_>>> {
match self.get(key) {
None => Ok(None),
Some(v) if v.is_object() => Ok(Some(Reader { cmd: self.cmd, p: v })),
Some(_) => Err(bad(self.cmd, format!("`{key}` must be an object"))),
}
}
}
fn num_in(cmd: &str, key: &str, v: &Value, lo: f32, hi: f32) -> Result<f32> {
let x = v.as_f64().filter(|x| x.is_finite()).ok_or_else(|| bad(cmd, format!("`{key}` must be a number")))?;
Ok((x as f32).clamp(lo, hi))
}
fn color_of(cmd: &str, key: &str, v: &Value) -> Result<[f32; 3]> {
let err = || bad(cmd, format!("`{key}` must be \"#rrggbb\" or [r, g, b] in 0..1"));
match v {
Value::String(s) => {
let h = s.trim_start_matches('#');
let b = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).map(|x| f32::from(x) / 255.0);
match (h.len(), b(0), b(2), b(4)) {
(6 | 8, Some(r), Some(g), Some(bb)) => Ok([r, g, bb]),
_ => Err(err()),
}
}
Value::Array(a) if a.len() >= 3 => {
let c = |i: usize| a.get(i).and_then(Value::as_f64).filter(|x| x.is_finite()).map(|x| (x as f32).clamp(0.0, 1.0)).ok_or_else(err);
Ok([c(0)?, c(1)?, c(2)?])
}
_ => Err(err()),
}
}
pub fn hex(c: [f32; 3]) -> String {
let b = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
format!("#{:02x}{:02x}{:02x}", b(c[0]), b(c[1]), b(c[2]))
}
fn merge(mut a: Value, b: Value) -> Value {
if let (Some(ao), Value::Object(bo)) = (a.as_object_mut(), b) {
for (k, v) in bo {
ao.entry(k).or_insert(v);
}
}
a
}
fn mixer_row(v: &[f32]) -> [f32; 4] {
std::array::from_fn(|i| v.get(i).copied().unwrap_or(0.0).clamp(-200.0, 200.0) / 100.0)
}
fn hue_saturation(r: &Reader<'_>, base: Option<&Adjustment>) -> Result<Adjustment> {
let (h, s, l, c, mut ranges) = match base {
Some(Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges }) => (*hue, *saturation, *lightness, *colorize, ranges.clone()),
_ => (0.0, 0.0, 0.0, false, HueRange::defaults()),
};
let colorize = r.boolean("colorize", c)?;
// Colorize hue is 0..360, a shift is -180..180.
let hue = if colorize { r.num("hue", h.rem_euclid(360.0), 0.0, 360.0)? } else { r.num("hue", h, -180.0, 180.0)? };
let (saturation, lightness) = (r.num("saturation", s, if colorize { 0.0 } else { -100.0 }, 100.0)?, r.num("lightness", l, -100.0, 100.0)?);
for (i, key) in HUE_RANGES.iter().enumerate() {
let Some(o) = r.object(key)? else { continue };
let Some(range) = ranges.get_mut(i) else { continue };
range.hue = o.num("hue", range.hue, -180.0, 180.0)?;
range.saturation = o.num("saturation", range.saturation, -100.0, 100.0)?;
range.lightness = o.num("lightness", range.lightness, -100.0, 100.0)?;
if let Some(b) = o.numbers("range", 4)? {
// Keep the four sliders ordered and within one turn of the first.
range.bounds = HueRange::canonical_bounds([b[0], b[1], b[2], b[3]]);
}
}
Ok(Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges })
}
/// Which tone space the params address: any channel key of a space picks it.
fn space_of(r: &Reader<'_>, base: Option<ToneSpace>, mode: ColorMode) -> ToneSpace {
let has = |keys: &[&str]| keys.iter().any(|k| r.get(k).is_some());
if has(&["cyan", "magenta", "yellow", "black"]) {
ToneSpace::Cmyk
} else if has(&["a", "b"]) || (has(&["lightness"]) && !has(&["red", "green", "blue"])) {
ToneSpace::Lab
} else if has(&["red", "green", "blue"]) {
ToneSpace::Rgb
} else {
base.unwrap_or_else(|| default_space(mode))
}
}
fn levels(r: &Reader<'_>, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
let (mut master, mut chans, base_space) = match base {
Some(Adjustment::Levels { master, per_channel, space, black }) => {
(master.clone(), [per_channel[0].clone(), per_channel[1].clone(), per_channel[2].clone(), black.clone()], Some(*space))
}
_ => (LevelsChannel::default(), Default::default(), None),
};
let space = space_of(r, base_space, mode);
if base_space.is_some_and(|b| b != space) {
chans = Default::default();
}
let read = |o: &Reader<'_>, c: &LevelsChannel| -> Result<LevelsChannel> {
let in_black = o.num("inBlack", c.in_black * 255.0, 0.0, 253.0)?;
let in_white = o.num("inWhite", c.in_white * 255.0, 2.0, 255.0)?.max(in_black + 2.0);
Ok(LevelsChannel {
in_black: in_black / 255.0,
in_white: in_white / 255.0,
gamma: o.num("gamma", c.gamma, 0.01, 9.99)?,
out_black: o.num("outBlack", c.out_black * 255.0, 0.0, 255.0)? / 255.0,
out_white: o.num("outWhite", c.out_white * 255.0, 0.0, 255.0)? / 255.0,
})
};
if space == ToneSpace::Lab {
// Top-level keys (and "gray") address lightness: Lab has no composite.
chans[0] = read(r, &chans[0])?;
} else {
master = read(r, &master)?;
if let Some(o) = r.object("gray")? {
master = read(&o, &master)?;
}
}
for (i, key) in channel_keys(space).iter().enumerate() {
if let (Some(o), Some(c)) = (r.object(key)?, chans.get(i).cloned()) {
chans[i] = read(&o, &c)?;
}
}
let [c0, c1, c2, black] = chans;
Ok(Adjustment::Levels { master, per_channel: [c0, c1, c2], space, black: if space == ToneSpace::Cmyk { black } else { LevelsChannel::default() } })
}
fn identity_curve() -> Vec<CurvePoint> {
vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]
}
fn curve_arr(c: &[CurvePoint]) -> Value {
let c = if c.len() >= 2 { c.to_vec() } else { identity_curve() };
json!(c.iter().map(|p| [(p.input * 255.0 * 100.0).round() / 100.0, (p.output * 255.0 * 100.0).round() / 100.0]).collect::<Vec<_>>())
}
/// Most points a curve may have (Photoshop allows 16; the PSD record holds 19).
pub const MAX_CURVE_POINTS: usize = 19;
/// `[[in, out], …]` in 0..255: sorted by input, duplicates collapsed, 2..=19 points.
pub fn parse_curve(cmd: &str, key: &str, v: &Value) -> Result<Vec<CurvePoint>> {
let err = || bad(cmd, format!("`{key}` must be an array of 2..={MAX_CURVE_POINTS} [input, output] pairs in 0..255"));
let a = v.as_array().ok_or_else(err)?;
if a.len() < 2 || a.len() > 64 {
return Err(err());
}
let mut pts = Vec::with_capacity(a.len());
for q in a {
let q = q.as_array().filter(|q| q.len() == 2).ok_or_else(err)?;
let x = q.first().and_then(Value::as_f64).filter(|x| x.is_finite()).ok_or_else(err)?;
let y = q.get(1).and_then(Value::as_f64).filter(|y| y.is_finite()).ok_or_else(err)?;
pts.push(CurvePoint { input: (x as f32).clamp(0.0, 255.0) / 255.0, output: (y as f32).clamp(0.0, 255.0) / 255.0 });
}
pts.sort_by(|a, b| a.input.total_cmp(&b.input));
pts.dedup_by(|later, earlier| (later.input - earlier.input).abs() < 0.5 / 255.0);
if pts.len() < 2 || pts.len() > MAX_CURVE_POINTS {
return Err(err());
}
Ok(pts)
}
fn curves(r: &Reader<'_>, base: Option<&Adjustment>, mode: ColorMode) -> Result<Adjustment> {
let (mut master, mut chans, base_space) = match base {
Some(Adjustment::Curves { master, per_channel, space, black }) => {
(master.clone(), [per_channel[0].clone(), per_channel[1].clone(), per_channel[2].clone(), black.clone()], Some(*space))
}
_ => (identity_curve(), std::array::from_fn(|_| identity_curve()), None),
};
let space = space_of(r, base_space, mode);
if base_space.is_some_and(|b| b != space) {
chans = std::array::from_fn(|_| identity_curve());
}
let composite = r.get("points").or_else(|| r.get("gray"));
if let Some(v) = composite {
let c = parse_curve(r.cmd, "points", v)?;
if space == ToneSpace::Lab {
chans[0] = c;
} else {
master = c;
}
}
for (i, key) in channel_keys(space).iter().enumerate() {
if let Some(v) = r.get(key)
&& let Some(slot) = chans.get_mut(i)
{
*slot = parse_curve(r.cmd, key, v)?;
}
}
let [c0, c1, c2, black] = chans;
let black = if space == ToneSpace::Cmyk && !photocraft_doc::adjust::is_identity_curve(&black) { black } else { Vec::new() };
Ok(Adjustment::Curves { master, per_channel: [c0, c1, c2], space, black })
}
fn levels_obj(c: &LevelsChannel) -> Value {
let q = |v: f32| (v * 255.0 * 100.0).round() / 100.0;
json!({"inBlack": q(c.in_black), "gamma": (c.gamma * 1000.0).round() / 1000.0, "inWhite": q(c.in_white), "outBlack": q(c.out_black), "outWhite": q(c.out_white)})
}
/// `[[location 0..1, colour], …]` (colour as for `color`), sorted by location.
fn gradient_stops(cmd: &str, v: &Value) -> Result<Vec<(f32, [f32; 3])>> {
let err = || bad(cmd, "`stops` must be an array of 2..=64 [location 0..1, colour] pairs");
let a = v.as_array().filter(|a| (2..=64).contains(&a.len())).ok_or_else(err)?;
let mut out = Vec::with_capacity(a.len());
for s in a {
let (t, c) = match s {
Value::Array(p) if p.len() == 2 => (p.first(), p.get(1)),
Value::Object(o) => (o.get("location"), o.get("color")),
_ => return Err(err()),
};
let t = t.and_then(Value::as_f64).filter(|t| t.is_finite()).ok_or_else(err)?;
let c = color_of(cmd, "stops", c.ok_or_else(err)?)?;
out.push(((t as f32).clamp(0.0, 1.0), c));
}
out.sort_by(|a, b| a.0.total_cmp(&b.0));
Ok(out)
}
/// Params with the private keys (`layer`, `coalesce`, `__kind`) removed.
pub fn user_keys(p: &Value) -> Map<String, Value> {
p.as_object()
.map(|o| {
o.iter()
.filter(|(k, _)| !matches!(k.as_str(), "layer" | "coalesce" | "channel" | "targetChannel") && !k.starts_with("__"))
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
const KINDS: [&str; 15] = [
"invert",
"brightnessContrast",
"threshold",
"posterize",
"exposure",
"vibrance",
"hueSaturation",
"levels",
"curves",
"colorBalance",
"blackWhite",
"photoFilter",
"channelMixer",
"gradientMap",
"selectiveColor",
];
/// A non-default parameter set per kind (RGB channel keys for Levels/Curves).
pub(crate) fn sample(kind: &str) -> Value {
match kind {
"brightnessContrast" => json!({"brightness": 20, "contrast": -10, "legacy": true}),
"threshold" => json!({"level": 100}),
"posterize" => json!({"levels": 7}),
"exposure" => json!({"exposure": 1.5, "offset": 0.1, "gamma": 1.4}),
"vibrance" => json!({"vibrance": 30, "saturation": -20}),
"hueSaturation" => {
json!({"hue": 25, "saturation": -20, "lightness": 10, "reds": {"hue": 10, "saturation": -30, "lightness": 5, "range": [300, 340, 20, 50]}})
}
"levels" => json!({"inBlack": 10, "inWhite": 240, "outBlack": 5, "outWhite": 250, "gamma": 1.2, "red": {"inBlack": 3, "gamma": 0.8}}),
"curves" => json!({"points": [[0, 0], [128, 150], [255, 255]], "blue": [[0, 20], [255, 235]]}),
"colorBalance" => json!({"shadows": [10, -5, 0], "midtones": [0, 20, -30], "highlights": [5, 5, 5], "preserveLuminosity": false}),
"blackWhite" => json!({"reds": 120, "blues": -40, "tint": true, "tintColor": "#d0a070"}),
"photoFilter" => json!({"color": "#3366cc", "density": 40, "preserveLuminosity": false}),
"channelMixer" => json!({"red": [80, 30, -10, 5], "blue": [0, 10, 90, 0]}),
"gradientMap" => json!({"stops": [[0, "#000000"], [0.5, "#ff0000"], [1, [1, 1, 1]]], "reverse": true, "dither": true}),
"selectiveColor" => json!({"method": "absolute", "reds": [10, 0, -20, 5]}),
_ => json!({}),
}
}
#[test]
fn every_kind_round_trips_through_params() {
for mode in [ColorMode::Rgb, ColorMode::Cmyk, ColorMode::Lab, ColorMode::Grayscale] {
for kind in KINDS {
let mut p = sample(kind);
if matches!(kind, "levels" | "curves") && default_space(mode) != ToneSpace::Rgb {
// Off RGB the channel keys are the document's.
p.as_object_mut().unwrap().retain(|k, _| !matches!(k.as_str(), "red" | "blue"));
}
let a = from_params(kind, &p, None, mode).unwrap_or_else(|e| panic!("{kind} {mode:?}: {e}"));
assert_ne!(Some(&a), default_for(kind, mode).ok().as_ref().filter(|_| kind != "invert"), "{kind} params ignored");
let back = from_params(kind, &to_params(&a), None, mode).unwrap();
assert_eq!(to_params(&back), to_params(&a), "{kind} {mode:?}");
// A partial update keeps everything it doesn't name.
let again = from_params(kind, &json!({}), Some(&a), mode).unwrap();
assert_eq!(again, a, "{kind} {mode:?}");
}
}
}
#[test]
fn params_reach_the_model() {
let a = from_params("colorBalance", &json!({"midtones": [50, 0, -200]}), None, ColorMode::Rgb).unwrap();
assert_eq!(a, Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [50.0, 0.0, -100.0], highlights: [0.0; 3], preserve_luminosity: true });
let a = from_params("channelMixer", &json!({"monochrome": true}), None, ColorMode::Rgb).unwrap();
assert!(matches!(a, Adjustment::ChannelMixer { matrix, monochrome: true } if matrix[0] == [0.4, 0.4, 0.2, 0.0]));
let a = from_params("photoFilter", &json!({"filter": "cooling80", "density": 60}), None, ColorMode::Rgb).unwrap();
assert!(matches!(a, Adjustment::PhotoFilter { color, density, .. } if color[2] == 1.0 && (density - 0.6).abs() < 1e-6));
let a = from_params("blackWhite", &json!({"reds": 120, "tint": true}), None, ColorMode::Rgb).unwrap();
assert!(matches!(a, Adjustment::BlackWhite { weights, tint: Some(_) } if weights[0] == 120.0 && weights[1] == 60.0));
let a = from_params("hueSaturation", &json!({"blues": {"hue": 40}}), None, ColorMode::Rgb).unwrap();
assert!(matches!(&a, Adjustment::HueSaturation { ranges, .. } if ranges[4].hue == 40.0 && ranges[0].is_neutral()));
let a = from_params("gradientMap", &json!({"stops": [[1, "#ffffff"], [0, "#000000"]], "dither": true}), None, ColorMode::Rgb).unwrap();
assert!(matches!(&a, Adjustment::GradientMap { stops, dither: true, .. } if stops[0].0 == 0.0));
// CMYK curves: ink channels; Lab: lightness without a composite.
let a = from_params("curves", &json!({"black": [[0, 0], [255, 200]]}), None, ColorMode::Rgb).unwrap();
assert!(matches!(&a, Adjustment::Curves { space: ToneSpace::Cmyk, black, .. } if black.len() == 2));
let a = from_params("curves", &json!({"points": [[0, 40], [255, 255]]}), None, ColorMode::Lab).unwrap();
assert!(matches!(&a, Adjustment::Curves { space: ToneSpace::Lab, per_channel, .. } if per_channel[0][0].output > 0.1));
let a = from_params("levels", &json!({"inBlack": 20}), None, ColorMode::Cmyk).unwrap();
assert!(matches!(&a, Adjustment::Levels { space: ToneSpace::Cmyk, master, .. } if (master.in_black - 20.0 / 255.0).abs() < 1e-6));
}
#[test]
fn bad_params_are_errors_not_panics() {
for (kind, p) in [
("curves", json!({"points": "abc"})),
("curves", json!({"points": [[0, 0]]})),
("curves", json!({"red": [[0, 0], [1]]})),
("curves", json!({"points": [[5, 5], [5, 9]]})),
("levels", json!({"red": 3})),
("levels", json!({"inBlack": "x"})),
("hueSaturation", json!({"reds": {"range": [1, 2]}})),
("hueSaturation", json!({"colorize": 1})),
("colorBalance", json!({"shadows": [1, 2]})),
("blackWhite", json!({"tintColor": "#zz0000"})),
("photoFilter", json!({"filter": "nope"})),
("photoFilter", json!({"color": [1]})),
("channelMixer", json!({"red": [1, 2, 3]})),
("gradientMap", json!({"stops": [[0, "#000"]]})),
("gradientMap", json!({"stops": 7})),
("nope", json!({})),
("levels", json!([1, 2])),
] {
assert!(from_params(kind, &p, None, ColorMode::Rgb).is_err(), "{kind} {p}");
}
// Out-of-range numbers clamp.
let a = from_params("hueSaturation", &json!({"hue": 1e9, "saturation": -1e9}), None, ColorMode::Rgb).unwrap();
assert!(matches!(a, Adjustment::HueSaturation { hue: 180.0, saturation: -100.0, .. }));
let a = from_params("levels", &json!({"inBlack": 250, "inWhite": 10}), None, ColorMode::Rgb).unwrap();
assert!(matches!(a, Adjustment::Levels { master, .. } if master.in_white > master.in_black));
}
#[test]
fn hue_range_sliders_stay_ordered() {
let a = from_params("hueSaturation", &json!({"greens": {"range": [100, 90, 200, 150]}}), None, ColorMode::Rgb).unwrap();
let Adjustment::HueSaturation { ranges, .. } = a else { panic!() };
let b = ranges[2].bounds;
assert!(b[0] <= b[1] && b[1] <= b[2] && b[2] <= b[3] && b[3] - b[0] < 360.0, "{b:?}");
}
/// A colourful 16×16 document (`mode`, `depth`) for command tests.
fn colourful(mode: &str, depth: u32) -> crate::Session {
let mut s = crate::Session::new();
s.execute("file.new", json!({"width": 16, "height": 16, "mode": mode, "depth": depth})).unwrap();
for (i, c) in ["#c83c28", "#3c8c50", "#2850b4", "#d2b464"].iter().enumerate() {
s.execute("select.rect", json!({"x": (i % 2) * 8, "y": (i / 2) * 8, "width": 8, "height": 8})).unwrap();
s.execute("edit.fill", json!({"color": c})).unwrap();
}
s.execute("select.deselect", json!({})).unwrap();
s
}
fn pixels(s: &crate::Session) -> Vec<Vec<f32>> {
let d = s.active().unwrap();
let bg = d.doc.layers[0].surface().unwrap();
[(2, 2), (10, 2), (2, 10), (10, 10)].iter().map(|&(x, y)| bg.pixel(x, y)).collect()
}
#[test]
fn destructive_commands_apply_their_params_at_every_depth() {
for depth in [8, 16, 32] {
for kind in KINDS.iter().filter(|k| **k != "invert") {
let mut s = colourful("rgb", depth);
let before = pixels(&s);
let steps = s.active().unwrap().history.past_len();
s.execute(&format!("image.adjustments.{kind}"), sample(kind)).unwrap_or_else(|e| panic!("{kind} @{depth}: {e}"));
assert_ne!(pixels(&s), before, "{kind} @{depth} changed nothing");
assert_eq!(s.active().unwrap().history.past_len(), steps + 1, "{kind}: one history step");
// Identity params leave the pixels alone (within one 8-bit step).
let mut s = colourful("rgb", depth);
s.execute(&format!("image.adjustments.{kind}"), to_params(&default_for(kind, ColorMode::Rgb).unwrap())).unwrap();
if !matches!(*kind, "threshold" | "posterize" | "blackWhite" | "gradientMap" | "photoFilter") {
for (a, b) in pixels(&s).iter().zip(&before) {
assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() <= 1.0 / 255.0 + 1e-4), "{kind} @{depth}: {a:?} vs {b:?}");
}
}
}
}
}
#[test]
fn cmyk_and_lab_documents_use_their_channels() {
let mut s = colourful("cmyk", 8);
let before = pixels(&s);
// More black ink darkens every patch.
s.execute("image.adjustments.curves", json!({"black": [[0, 0], [255, 180]]})).unwrap();
let after = pixels(&s);
assert!(after.iter().zip(&before).all(|(a, b)| a[3] > b[3] + 0.05), "{after:?} vs {before:?}");
let mut s = colourful("lab", 16);
let before = pixels(&s);
s.execute("image.adjustments.levels", json!({"lightness": {"outBlack": 60}})).unwrap();
let after = pixels(&s);
assert!(after.iter().zip(&before).all(|(a, b)| a[0] >= b[0]), "lightness lifted {after:?} vs {before:?}");
// The adjustment layer of a CMYK document defaults to ink channels.
let mut s = colourful("cmyk", 8);
s.execute("layer.newAdjustmentLayer.curves", json!({})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let l = s.active().unwrap().doc.layer(id).unwrap().clone();
assert!(matches!(l.content, photocraft_doc::LayerContent::Adjustment(Adjustment::Curves { space: ToneSpace::Cmyk, .. })));
}
#[test]
fn set_adjustment_merges_and_resets() {
let mut s = colourful("rgb", 8);
s.execute("layer.newAdjustmentLayer.colorBalance", json!({"midtones": [30, 0, 0]})).unwrap();
s.execute("layer.setAdjustment", json!({"highlights": [0, 0, -20]})).unwrap();
let id = s.active().unwrap().active_layer.unwrap();
let adj = |s: &crate::Session| match &s.active().unwrap().doc.layer(id).unwrap().content {
photocraft_doc::LayerContent::Adjustment(a) => a.clone(),
_ => panic!(),
};
assert!(matches!(adj(&s), Adjustment::ColorBalance { midtones: [30.0, 0.0, 0.0], highlights: [0.0, 0.0, -20.0], .. }));
s.execute("layer.setAdjustment", json!({"layer": id.0})).unwrap();
assert_eq!(adj(&s), default_for("colorBalance", ColorMode::Rgb).unwrap());
// Bad params are errors and leave the layer alone.
s.execute("layer.setAdjustment", json!({"midtones": [10, 10, 10]})).unwrap();
let kept = adj(&s);
for bad in [json!({"midtones": "x"}), json!({"preserveLuminosity": 3}), json!({"shadows": [1]})] {
assert!(s.execute("layer.setAdjustment", bad.clone()).is_err(), "{bad}");
assert_eq!(adj(&s), kept);
}
}
#[test]
fn commands_reject_bad_params_gracefully() {
let mut s = colourful("rgb", 8);
for (kind, p) in [
("curves", json!({"points": [[0, 0]]})),
("levels", json!({"red": "dark"})),
("gradientMap", json!({"stops": [[0, 1, 2]]})),
("photoFilter", json!({"filter": 3})),
("channelMixer", json!({"gray": [1, 2]})),
("hueSaturation", json!({"reds": 4})),
("blackWhite", json!({"tint": "yes"})),
] {
for prefix in ["image.adjustments.", "layer.newAdjustmentLayer."] {
let r = s.execute(&format!("{prefix}{kind}"), p.clone());
assert!(matches!(r, Err(EngineError::BadParams { .. })), "{prefix}{kind} {p}: {r:?}");
}
}
}
}

View File

@ -1,7 +1,6 @@
//! The command registry. Ids follow Photoshop's menu structure (see the parity checklist). //! The command registry. Ids follow Photoshop's menu structure (see the parity checklist).
use photocraft_color::{BlendMode, Color, ColorMode, SampleType}; use photocraft_color::{BlendMode, Color, ColorMode, SampleType};
use photocraft_doc::adjust::CurvePoint;
use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size}; use photocraft_doc::{Adjustment, Document, Fill, Layer, LayerContent, LayerId, LayerMask, Size};
use photocraft_geom::Rect; use photocraft_geom::Rect;
use photocraft_raster::Surface; use photocraft_raster::Surface;
@ -165,7 +164,7 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
return s.edit(label, |doc, _| { return s.edit(label, |doc, _| {
let sel = doc.selection.clone(); let sel = doc.selection.clone();
if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, p)? { if let Some(surf) = crate::channel_cmds::channel_surface_for_filter(doc, p)? {
pixels::adjust_surface(surf, &adj, sel.as_ref()); pixels::adjust_surface(surf, &adj, sel.as_ref(), ColorMode::Grayscale);
surf.prune(); surf.prune();
} }
Ok(Value::Null) Ok(Value::Null)
@ -174,84 +173,25 @@ fn destructive_adjust(s: &mut Session, label: &str, adj: Adjustment, p: &Value)
let id = layer_param(s, &Value::Null)?; let id = layer_param(s, &Value::Null)?;
s.edit(label, |doc, _| { s.edit(label, |doc, _| {
let sel = doc.selection.clone(); let sel = doc.selection.clone();
let mode = doc.mode;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?; let surf = l.surface_mut().ok_or_else(|| EngineError::Other("not a pixel layer".into()))?;
pixels::adjust_surface(surf, &adj, sel.as_ref()); pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
Ok(()) Ok(())
})?; })?;
Ok(Value::Null) Ok(Value::Null)
} }
/// Lenient adjustment from params for previews: bad params fall back to the kind's defaults.
/// Commands use the checked [`crate::adjust_params::from_params`].
pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment { pub fn adjustment_from_params(kind: &str, p: &Value) -> Adjustment {
match kind { crate::adjust_params::from_params(kind, p, None, ColorMode::Rgb)
"invert" => Adjustment::Invert, .or_else(|_| crate::adjust_params::default_for(kind, ColorMode::Rgb))
"hueSaturation" => Adjustment::HueSaturation { .unwrap_or(Adjustment::Invert)
hue: f32_or(p, "hue", 0.0), }
saturation: f32_or(p, "saturation", 0.0),
lightness: f32_or(p, "lightness", 0.0), fn doc_mode(s: &Session) -> ColorMode {
colorize: p.get("colorize").and_then(Value::as_bool).unwrap_or(false), s.active().map_or(ColorMode::Rgb, |d| d.doc.mode)
},
"brightnessContrast" => {
Adjustment::BrightnessContrast { brightness: f32_or(p, "brightness", 0.0), contrast: f32_or(p, "contrast", 0.0), legacy: false }
}
"threshold" => Adjustment::Threshold { level: f32_or(p, "level", 128.0) / 255.0 },
"posterize" => Adjustment::Posterize { levels: f32_or(p, "levels", 4.0) as u32 },
"exposure" => Adjustment::Exposure { exposure: f32_or(p, "exposure", 0.0), offset: f32_or(p, "offset", 0.0), gamma: f32_or(p, "gamma", 1.0) },
"vibrance" => Adjustment::Vibrance { vibrance: f32_or(p, "vibrance", 0.0), saturation: f32_or(p, "saturation", 0.0) },
"levels" => {
// Master keys at the top level; per channel under "red"/"green"/"blue" (0–255 levels).
let ch = |p: &Value| photocraft_doc::adjust::LevelsChannel {
in_black: f32_or(p, "inBlack", 0.0) / 255.0,
in_white: f32_or(p, "inWhite", 255.0) / 255.0,
gamma: f32_or(p, "gamma", 1.0).clamp(0.01, 9.99),
out_black: f32_or(p, "outBlack", 0.0) / 255.0,
out_white: f32_or(p, "outWhite", 255.0) / 255.0,
};
let per = |k: &str| p.get(k).map(ch).unwrap_or_default();
Adjustment::Levels { master: ch(p), per_channel: [per("red"), per("green"), per("blue")] }
}
"curves" => {
// "points" = master curve; "red"/"green"/"blue" = per-channel curves ([[in, out], …], 0–255).
let parse = |v: Option<&Value>| -> Option<Vec<CurvePoint>> {
let pts: Vec<CurvePoint> = v?
.as_array()?
.iter()
.filter_map(|v| {
let a = v.as_array()?;
Some(CurvePoint { input: a.first()?.as_f64()? as f32 / 255.0, output: a.get(1)?.as_f64()? as f32 / 255.0 })
})
.collect();
(pts.len() >= 2).then_some(pts)
};
let mut a = Adjustment::identity_curves();
if let Adjustment::Curves { master, per_channel } = &mut a {
if let Some(m) = parse(p.get("points")) {
*master = m;
}
for (i, k) in ["red", "green", "blue"].iter().enumerate() {
if let Some(c) = parse(p.get(*k)) {
per_channel[i] = c;
}
}
}
a
}
"blackWhite" => Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None },
"photoFilter" => Adjustment::PhotoFilter { color: [0.925, 0.541, 0.0], density: f32_or(p, "density", 25.0) / 100.0, preserve_luminosity: true },
"channelMixer" => Adjustment::ChannelMixer { matrix: [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]], monochrome: false },
"colorBalance" => Adjustment::ColorBalance { shadows: [0.0; 3], midtones: [0.0; 3], highlights: [0.0; 3], preserve_luminosity: true },
"gradientMap" => Adjustment::GradientMap { stops: vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])], reverse: false },
"selectiveColor" => crate::adjust_cmds::selective_from_params(p, None),
// Best effort here (a bad LUT file gives an empty lookup); commands use the checked path.
"colorLookup" => crate::adjust_cmds::lookup_from_params(p, None).unwrap_or(Adjustment::ColorLookup {
name: String::new(),
lut: None,
size: 0,
tetrahedral: false,
dither: false,
}),
_ => Adjustment::Invert,
}
} }
// ---------- the table ---------- // ---------- the table ----------
@ -690,12 +630,18 @@ fn build() -> Vec<CommandSpec> {
cmd!("layer.setAdjustment", "Adjustment Properties", [], None, r##"{"layer":id?, …params of that adjustment kind}"##, has_layer, |s, p| { cmd!("layer.setAdjustment", "Adjustment Properties", [], None, r##"{"layer":id?, …params of that adjustment kind}"##, has_layer, |s, p| {
let id = layer_param(s, p)?; let id = layer_param(s, p)?;
s.edit("Modify Adjustment", |doc, _| { s.edit("Modify Adjustment", |doc, _| {
let mode = doc.mode;
let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?; let l = doc.layer_mut(id).ok_or(EngineError::NoLayer(id))?;
let LayerContent::Adjustment(adj) = &mut l.content else { return Err(EngineError::Other("not an adjustment layer".into())) }; let LayerContent::Adjustment(adj) = &mut l.content else { return Err(EngineError::Other("not an adjustment layer".into())) };
if matches!(adj, Adjustment::Unsupported { .. }) {
return Err(EngineError::Other("this adjustment can't be edited (kept as imported)".into()));
}
let kind = adjustment_kind(adj); let kind = adjustment_kind(adj);
*adj = match crate::adjust_cmds::update_adjustment(adj, p) { // No params resets to the defaults; otherwise params merge over the current values.
Some(r) => r?, *adj = if crate::adjust_params::user_keys(p).is_empty() {
None => adjustment_from_params(kind, p), crate::adjust_params::default_for(kind, mode)?
} else {
crate::adjust_params::from_params(kind, p, Some(adj), mode)?
}; };
Ok(()) Ok(())
})?; })?;
@ -791,7 +737,18 @@ fn build() -> Vec<CommandSpec> {
Ok(Value::Null) Ok(Value::Null)
}), }),
cmd!("image.adjustments.desaturate", "Desaturate", ["Image", "Adjustments"], Some("Cmd+Shift+U"), "{}", has_pixel_layer, |s, _| { cmd!("image.adjustments.desaturate", "Desaturate", ["Image", "Adjustments"], Some("Cmd+Shift+U"), "{}", has_pixel_layer, |s, _| {
destructive_adjust(s, "Desaturate", Adjustment::HueSaturation { hue: 0.0, saturation: -100.0, lightness: 0.0, colorize: false }, &Value::Null) destructive_adjust(
s,
"Desaturate",
Adjustment::HueSaturation {
hue: 0.0,
saturation: -100.0,
lightness: 0.0,
colorize: false,
ranges: photocraft_doc::adjust::HueRange::defaults(),
},
&Value::Null,
)
}), }),
// Paint // Paint
cmd!( cmd!(
@ -895,21 +852,51 @@ fn build() -> Vec<CommandSpec> {
]; ];
// Adjustment layers + destructive adjustments, generated from one list. // Adjustment layers + destructive adjustments, generated from one list.
// Levels/Curves channel keys follow the document: red/green/blue (RGB), gray (Grayscale),
// cyan/magenta/yellow/black (CMYK), lightness/a/b (Lab); see `adjust_params`.
const ADJ: &[(&str, &str, &str)] = &[ const ADJ: &[(&str, &str, &str)] = &[
("brightnessContrast", "Brightness/Contrast…", r##"{"brightness":-150..150,"contrast":-50..100}"##), ("brightnessContrast", "Brightness/Contrast…", r##"{"brightness":-150..150=0,"contrast":-50..100=0,"legacy":bool=false}"##),
("levels", "Levels…", r##"{"inBlack":0..255,"inWhite":0..255,"gamma":0.1..10}"##), (
("curves", "Curves…", r##"{"points":[[in,out],…] in 0..255}"##), "levels",
("exposure", "Exposure…", r##"{"exposure":-20..20,"offset":-0.5..0.5,"gamma":0.01..9.99}"##), "Levels…",
("vibrance", "Vibrance…", r##"{"vibrance":-100..100,"saturation":-100..100}"##), r##"{"inBlack":0..253=0,"gamma":0.01..9.99=1,"inWhite":2..255=255,"outBlack":0..255=0,"outWhite":0..255=255,"red":json,"green":json,"blue":json} (top level = composite; per channel {"inBlack","gamma","inWhite","outBlack","outWhite"} under red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b)"##,
("hueSaturation", "Hue/Saturation…", r##"{"hue":-180..180,"saturation":-100..100,"lightness":-100..100,"colorize":bool}"##), ),
("colorBalance", "Color Balance…", "{}"), (
("blackWhite", "Black & White…", "{}"), "curves",
("photoFilter", "Photo Filter…", r##"{"density":0..100}"##), "Curves…",
("channelMixer", "Channel Mixer…", "{}"), r##"{"points":json,"red":json,"green":json,"blue":json} (curves as [[in,out],…] in 0..255, 2..19 points: points = composite; red/green/blue, gray, cyan/magenta/yellow/black or lightness/a/b per channel)"##,
),
("exposure", "Exposure…", r##"{"exposure":-20..20=0,"offset":-0.5..0.5=0,"gamma":0.01..9.99=1}"##),
("vibrance", "Vibrance…", r##"{"vibrance":-100..100=0,"saturation":-100..100=0}"##),
(
"hueSaturation",
"Hue/Saturation…",
r##"{"hue":-180..180=0,"saturation":-100..100=0,"lightness":-100..100=0,"colorize":bool=false,"reds":json,"yellows":json,"greens":json,"cyans":json,"blues":json,"magentas":json} (per range {"hue","saturation","lightness","range":[4 hue degrees]}; colorize: hue 0..360, saturation 0..100)"##,
),
(
"colorBalance",
"Color Balance…",
r##"{"shadows":json,"midtones":json,"highlights":json,"preserveLuminosity":bool=true} (each tone [cyan-red, magenta-green, yellow-blue] in -100..100)"##,
),
(
"blackWhite",
"Black & White…",
r##"{"reds":-200..300=40,"yellows":-200..300=60,"greens":-200..300=40,"cyans":-200..300=60,"blues":-200..300=20,"magentas":-200..300=80,"tint":bool=false,"tintColor":"#rrggbb"}"##,
),
(
"photoFilter",
"Photo Filter…",
r##"{"filter":"warming85|warmingLBA|warming81|cooling80|coolingLBB|cooling82|red|orange|yellow|green|cyan|blue|violet|magenta|sepia|deepRed|deepBlue|deepEmerald|deepYellow|underwater","color":"#rrggbb","density":0..100=25,"preserveLuminosity":bool=true}"##,
),
(
"channelMixer",
"Channel Mixer…",
r##"{"red":json,"green":json,"blue":json,"gray":json,"monochrome":bool=false} (per output channel [red %, green %, blue %, constant %] in -200..200; gray = the monochrome mix)"##,
),
("invert", "Invert", "{}"), ("invert", "Invert", "{}"),
("posterize", "Posterize…", r##"{"levels":2..255}"##), ("posterize", "Posterize…", r##"{"levels":2..255=4}"##),
("threshold", "Threshold…", r##"{"level":1..255}"##), ("threshold", "Threshold…", r##"{"level":1..255=128}"##),
("gradientMap", "Gradient Map…", "{}"), ("gradientMap", "Gradient Map…", r##"{"stops":json,"reverse":bool=false,"dither":bool=false} (stops [[location 0..1, "#rrggbb"], …], 2..64)"##),
( (
"selectiveColor", "selectiveColor",
"Selective Color…", "Selective Color…",
@ -933,7 +920,7 @@ fn build() -> Vec<CommandSpec> {
enabled: has_doc, enabled: has_doc,
run: |s, p| { run: |s, p| {
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
let adj = if kind == "colorLookup" { crate::adjust_cmds::lookup_from_params(p, None)? } else { adjustment_from_params(&kind, p) }; let adj = crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?;
new_adjustment(s, adj) new_adjustment(s, adj)
}, },
journal: true, journal: true,
@ -955,7 +942,7 @@ fn build() -> Vec<CommandSpec> {
enabled: has_pixel_or_channel, enabled: has_pixel_or_channel,
run: |s, p| { run: |s, p| {
let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string(); let kind = p.get("__kind").and_then(Value::as_str).unwrap_or("invert").to_string();
let adj = if kind == "colorLookup" { crate::adjust_cmds::lookup_from_params(p, None)? } else { adjustment_from_params(&kind, p) }; let adj = crate::adjust_params::from_params(&kind, p, None, doc_mode(s))?;
let label = adj.label().to_string(); let label = adj.label().to_string();
destructive_adjust(s, &label, adj, p) destructive_adjust(s, &label, adj, p)
}, },

View File

@ -298,7 +298,7 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
} }
} }
} }
let adj = Adjustment::Levels { master: LevelsChannel::default(), per_channel: per }; let adj = Adjustment::Levels { master: LevelsChannel::default(), per_channel: per, space: Default::default(), black: LevelsChannel::default() };
let label = match kind { let label = match kind {
"contrast" => "Auto Contrast", "contrast" => "Auto Contrast",
"color" => "Auto Color", "color" => "Auto Color",
@ -306,8 +306,9 @@ fn auto_adjust(s: &mut Session, kind: &str) -> Result<Value> {
}; };
s.edit(label, |doc, _| { s.edit(label, |doc, _| {
let sel = doc.selection.clone(); let sel = doc.selection.clone();
let mode = doc.mode;
let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or(EngineError::NoLayer(id))?; let surf = doc.layer_mut(id).and_then(|l| l.surface_mut()).ok_or(EngineError::NoLayer(id))?;
crate::pixels::adjust_surface(surf, &adj, sel.as_ref()); crate::pixels::adjust_surface(surf, &adj, sel.as_ref(), mode);
Ok(()) Ok(())
})?; })?;
Ok(Value::Null) Ok(Value::Null)

View File

@ -8,6 +8,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod adjust_cmds; pub mod adjust_cmds;
pub mod adjust_params;
pub mod align_cmds; pub mod align_cmds;
pub mod analysis_cmds; pub mod analysis_cmds;
pub mod artboard_cmds; pub mod artboard_cmds;

View File

@ -6,7 +6,9 @@ use photocraft_geom::Rect;
use photocraft_raster::{Surface, from_rgba, to_rgba}; use photocraft_raster::{Surface, from_rgba, to_rgba};
/// Apply an adjustment destructively to a surface, weighted by an optional selection. /// Apply an adjustment destructively to a surface, weighted by an optional selection.
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>) { /// Applies `adj` to a surface (any colour model and depth, via straight RGBA) through the
/// selection; `mode` is the document's, for the tone transfer (e.g. Exposure in Grayscale).
pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surface>, mode: photocraft_color::ColorMode) {
let r = s.content_bounds(); let r = s.content_bounds();
if r.is_empty() { if r.is_empty() {
return; return;
@ -16,7 +18,7 @@ pub fn adjust_surface(s: &mut Surface, adj: &Adjustment, selection: Option<&Surf
let raw = s.read_region(r); let raw = s.read_region(r);
let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() }; let mut buf = Buffer { rect: r, px: raw.chunks_exact(n).map(|p| to_rgba(&fmt, p)).collect() };
let orig = buf.clone(); let orig = buf.clone();
adjust::apply(adj, &mut buf); adjust::apply_with(adj, &mut buf, adjust::Transfer::for_mode(mode));
let w = r.width() as usize; let w = r.width() as usize;
let mut out = Vec::with_capacity(raw.len()); let mut out = Vec::with_capacity(raw.len());
for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() { for (i, (a, o)) in buf.px.iter().zip(&orig.px).enumerate() {

View File

@ -397,12 +397,12 @@ fn type_edit_rerenders_cache_to_new_text() {
fn levels_and_curves_params_cover_output_and_channels() { fn levels_and_curves_params_cover_output_and_channels() {
use photocraft_doc::Adjustment; use photocraft_doc::Adjustment;
let a = crate::commands::adjustment_from_params("levels", &json!({"inBlack": 10, "outWhite": 200, "green": {"gamma": 1.5}})); let a = crate::commands::adjustment_from_params("levels", &json!({"inBlack": 10, "outWhite": 200, "green": {"gamma": 1.5}}));
let Adjustment::Levels { master, per_channel } = a else { panic!() }; let Adjustment::Levels { master, per_channel, .. } = a else { panic!() };
assert!((master.in_black - 10.0 / 255.0).abs() < 1e-6 && (master.out_white - 200.0 / 255.0).abs() < 1e-6); assert!((master.in_black - 10.0 / 255.0).abs() < 1e-6 && (master.out_white - 200.0 / 255.0).abs() < 1e-6);
assert_eq!(per_channel[1].gamma, 1.5); assert_eq!(per_channel[1].gamma, 1.5);
assert_eq!(per_channel[0].gamma, 1.0); assert_eq!(per_channel[0].gamma, 1.0);
let c = crate::commands::adjustment_from_params("curves", &json!({"points": [[0, 0], [128, 160], [255, 255]], "blue": [[0, 20], [255, 235]]})); let c = crate::commands::adjustment_from_params("curves", &json!({"points": [[0, 0], [128, 160], [255, 255]], "blue": [[0, 20], [255, 235]]}));
let Adjustment::Curves { master, per_channel } = c else { panic!() }; let Adjustment::Curves { master, per_channel, .. } = c else { panic!() };
assert_eq!(master.len(), 3); assert_eq!(master.len(), 3);
assert!((per_channel[2][0].output - 20.0 / 255.0).abs() < 1e-6); assert!((per_channel[2][0].output - 20.0 / 255.0).abs() < 1e-6);
assert_eq!(per_channel[0].len(), 2); assert_eq!(per_channel[0].len(), 2);

View File

@ -75,7 +75,16 @@ pub fn rich_doc(mode: ColorMode, depth: SampleType) -> Document {
let mut inner = Layer::raster("Inner", pf); let mut inner = Layer::raster("Inner", pf);
scribble(inner.surface_mut().unwrap(), 6, hdr); scribble(inner.surface_mut().unwrap(), 6, hdr);
let nested = Layer::group("Nested", vec![inner]); let nested = Layer::group("Nested", vec![inner]);
let mut adj = Layer::new("Hue/Sat", LayerContent::Adjustment(Adjustment::HueSaturation { hue: 30.0, saturation: -10.0, lightness: 5.0, colorize: false })); let mut adj = Layer::new(
"Hue/Sat",
LayerContent::Adjustment(Adjustment::HueSaturation {
hue: 30.0,
saturation: -10.0,
lightness: 5.0,
colorize: false,
ranges: photocraft_doc::adjust::HueRange::defaults(),
}),
);
adj.visible = false; adj.visible = false;
let mut group = Layer::group("Group", vec![nested, adj]); let mut group = Layer::group("Group", vec![nested, adj]);
if let LayerContent::Group(g) = &mut group.content { if let LayerContent::Group(g) = &mut group.content {

View File

@ -482,18 +482,30 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
let hsl = rgb_to_hsl(c); let hsl = rgb_to_hsl(c);
var hh: f32; var hh: f32;
var ss: f32; var ss: f32;
// Range edits (LUT rows indexed by the original hue, faded out towards grey).
var dh = 0.0;
var ds = 0.0;
var dl = 0.0;
if (p1.x > 0.5) {
let chroma = min((max(c.r, max(c.g, c.b)) - min(c.r, min(c.g, c.b))) * 4.0, 1.0);
dh = lut(0, hsl.x) * chroma;
ds = lut(1, hsl.x) * chroma;
dl = lut(2, hsl.x) * chroma;
}
let sat = clamp(p0.y + ds, -1.0, 1.0);
let light = clamp(p0.z + dl, -1.0, 1.0);
if (p0.w > 0.5) { if (p0.w > 0.5) {
hh = rem_euclid(p0.x, 360.0) / 360.0; hh = rem_euclid(p0.x, 360.0) / 360.0;
ss = max(abs(p0.y), 0.25); ss = max(abs(sat), 0.25);
} else { } else {
hh = rem_euclid(hsl.x + p0.x / 360.0, 1.0); hh = rem_euclid(hsl.x + (p0.x + dh) / 360.0, 1.0);
ss = clamp(hsl.y * (1.0 + p0.y), 0.0, 1.0); ss = clamp(hsl.y * (1.0 + sat), 0.0, 1.0);
} }
var rgb = hsl_to_rgb(hh, ss, hsl.z); var rgb = hsl_to_rgb(hh, ss, hsl.z);
if (p0.z > 0.0) { if (light > 0.0) {
rgb = rgb + (1.0 - rgb) * p0.z; rgb = rgb + (1.0 - rgb) * light;
} else if (p0.z < 0.0) { } else if (light < 0.0) {
rgb = rgb * (1.0 + p0.z); rgb = rgb * (1.0 + light);
} }
return rgb; return rgb;
} }
@ -518,15 +530,16 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
return f; return f;
} }
case 12: { // Black & White case 12: { // Black & White
let hsl = rgb_to_hsl(c); // compose::adjust::black_white_gray: grey + secondary + primary parts, weighted.
let base = gray(c); var w = array<f32, 6>(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y);
let sector = hsl.x * 6.0; let mx = max(c.r, max(c.g, c.b));
let i0 = u32(floor(sector)) % 6u; let mn = min(c.r, min(c.g, c.b));
let i1 = (i0 + 1u) % 6u; let mid = c.r + c.g + c.b - mx - mn;
let f = sector - trunc(sector); var primary = 4;
var w = array<f32, 6>(p0.x - 40.0, p0.y - 60.0, p0.z - 40.0, p0.w - 60.0, p1.x - 20.0, p1.y - 80.0); if (c.r >= c.g && c.r >= c.b) { primary = 0; } else if (c.g >= c.b) { primary = 2; }
let wt = w[i0] * (1.0 - f) + w[i1] * f; var secondary = 5;
let g = clamp(base + hsl.y * wt / 100.0 * 0.5, 0.0, 1.0); if (c.b <= c.r && c.b <= c.g) { secondary = 1; } else if (c.r <= c.g) { secondary = 3; }
let g = clamp(mn + (mid - mn) * w[secondary] / 100.0 + (mx - mid) * w[primary] / 100.0, 0.0, 1.0);
if (p1.z > 0.5) { if (p1.z > 0.5) {
return clamp(g * p2.rgb * 2.0, vec3(0.0), vec3(1.0)) * 0.5 + g * 0.5; return clamp(g * p2.rgb * 2.0, vec3(0.0), vec3(1.0)) * 0.5 + g * 0.5;
} }
@ -535,7 +548,9 @@ fn adjust(c: vec3<f32>) -> vec3<f32> {
case 13: { // Gradient map case 13: { // Gradient map
var t = gray(c); var t = gray(c);
if (p0.x > 0.5) { t = 1.0 - t; } if (p0.x > 0.5) { t = 1.0 - t; }
return vec3(lut(0, t), lut(1, t), lut(2, t)); var o = vec3(lut(0, t), lut(1, t), lut(2, t));
if (p0.y > 0.5) { o = clamp(o + bayer4(adj_px) / 255.0, vec3(0.0), vec3(1.0)); }
return o;
} }
case 14: { // Color balance case 14: { // Color balance
let l = gray(c); let l = gray(c);

View File

@ -15,6 +15,7 @@
use photocraft_color::BlendMode; use photocraft_color::BlendMode;
use photocraft_compose::adjust::{self, Transfer}; use photocraft_compose::adjust::{self, Transfer};
use photocraft_compose::effects::has_effects; use photocraft_compose::effects::has_effects;
use photocraft_doc::adjust::ToneSpace;
use photocraft_doc::{Adjustment, Document, Effect, Fill, FxPaint, GlobalLight, Gradient, Layer, LayerContent, LayerId, Pattern, StrokePosition}; use photocraft_doc::{Adjustment, Document, Effect, Fill, FxPaint, GlobalLight, Gradient, Layer, LayerContent, LayerId, Pattern, StrokePosition};
use photocraft_geom::Rect; use photocraft_geom::Rect;
use photocraft_raster::Surface; use photocraft_raster::Surface;
@ -585,7 +586,7 @@ impl<'a> Planner<'a> {
if let LayerContent::Adjustment(adj) = &layer.content { if let LayerContent::Adjustment(adj) = &layer.content {
let before = self.retain(backdrop); let before = self.retain(backdrop);
let mut adjusted = self.adjust(adj, backdrop); let mut adjusted = self.adjust(adj, backdrop)?;
for c in visible_clipped { for c in visible_clipped {
adjusted = self.atop(c, adjusted)?; adjusted = self.atop(c, adjusted)?;
} }
@ -765,7 +766,7 @@ impl<'a> Planner<'a> {
let opacity = layer.opacity * layer.fill_opacity; let opacity = layer.opacity * layer.fill_opacity;
if let LayerContent::Adjustment(adj) = &layer.content { if let LayerContent::Adjustment(adj) = &layer.content {
let before = self.retain(base); let before = self.retain(base);
let adjusted = self.adjust(adj, base); let adjusted = self.adjust(adj, base)?;
let mut p = Pass::new(Kernel::AdjMix, 0); let mut p = Pass::new(Kernel::AdjMix, 0);
p.a = Some(before); p.a = Some(before);
p.b = Some(adjusted); p.b = Some(adjusted);
@ -789,14 +790,17 @@ impl<'a> Planner<'a> {
} }
/// adjust::apply_with on a slot (consumes it). /// adjust::apply_with on a slot (consumes it).
fn adjust(&mut self, adj: &Adjustment, src: Slot) -> Slot { fn adjust(&mut self, adj: &Adjustment, src: Slot) -> Result<Slot, Unsupported> {
if !adjustment_on_gpu(adj) {
return Err(Unsupported(format!("{} on CMYK/Lab channels (evaluated on the CPU)", adj.label())));
}
let mut p = Pass::new(Kernel::Adjust, 0); let mut p = Pass::new(Kernel::Adjust, 0);
p.a = Some(src); p.a = Some(src);
let (kind, params, lut) = adjustment_program(adj, self.cx.transfer); let (kind, params, lut) = adjustment_program(adj, self.cx.transfer);
p.adjust_kind = kind; p.adjust_kind = kind;
p.params = params; p.params = params;
p.lut = lut; p.lut = lut;
self.emit(p) Ok(self.emit(p))
} }
/// `composite_with_effects` (atop = false) or the clipped-layer variant of `composite_atop` /// `composite_with_effects` (atop = false) or the clipped-layer variant of `composite_atop`
@ -1215,6 +1219,21 @@ fn lut_rows(f: impl Fn(usize, f32) -> f32, rows: usize) -> Vec<[f32; 4096]> {
type Program = (i32, [[f32; 4]; 4], Option<Vec<[f32; 4096]>>); type Program = (i32, [[f32; 4]; 4], Option<Vec<[f32; 4096]>>);
/// A CPU LUT (4096 entries) as one texture row.
fn to_row(t: &[f32]) -> [f32; 4096] {
let mut row = [0.0f32; 4096];
for (o, v) in row.iter_mut().zip(t) {
*o = *v;
}
row
}
/// Whether the adjustment kernel can evaluate `adj`: Levels and Curves on CMYK ink or Lab
/// channels convert through ICC profiles per pixel, which only the CPU does.
pub fn adjustment_on_gpu(adj: &Adjustment) -> bool {
!matches!(adj, Adjustment::Levels { space: ToneSpace::Cmyk | ToneSpace::Lab, .. } | Adjustment::Curves { space: ToneSpace::Cmyk | ToneSpace::Lab, .. })
}
/// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`. /// Adjustment → (kernel kind, parameters, LUT rows). Kinds are the `switch` in `adjust()`.
pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program { pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
let mut p = [[0.0f32; 4]; 4]; let mut p = [[0.0f32; 4]; 4];
@ -1248,32 +1267,17 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), g]; p[0] = [2f32.powf(*exposure), *offset, gamma.max(0.01), g];
(6, p, None) (6, p, None)
} }
Adjustment::Levels { master, per_channel } => (7, p, Some(lut_rows(|i, v| adjust::levels(&per_channel[i], adjust::levels(master, v)), 3))), // RGB space only (see `adjustment_on_gpu`); the rows are the CPU's channel∘master LUTs.
Adjustment::Curves { master, per_channel } => { Adjustment::Levels { .. } | Adjustment::Curves { .. } => (7, p, Some(adjust::tone_luts(adj).iter().take(3).map(|t| to_row(t)).collect())),
let m = adjust::curve_lut(master); Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
let chans: Vec<Vec<f32>> = per_channel.iter().map(|c| adjust::curve_lut(c)).collect();
let lut = |t: &[f32], v: f32| {
let x = v.clamp(0.0, 1.0) * (t.len() - 1) as f32;
let i = x.floor() as usize;
let j = (i + 1).min(t.len() - 1);
let f = x - i as f32;
t[i] * (1.0 - f) + t[j] * f
};
// The CPU LUT is `m(ch[k])` per entry; entries are on the same 4096 grid.
let rows = (0..3)
.map(|r| {
let mut row = [0.0f32; 4096];
for (k, v) in row.iter_mut().enumerate() {
*v = lut(&m, chans[r][k.min(chans[r].len() - 1)]);
}
row
})
.collect();
(7, p, Some(rows))
}
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => {
p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }]; p[0] = [*hue, saturation / 100.0, lightness / 100.0, if *colorize { 1.0 } else { 0.0 }];
(8, p, None) if !*colorize && ranges.iter().any(|r| !r.is_neutral()) {
// Range edits: hue-indexed rows (shift, saturation, lightness), as on the CPU.
p[1][0] = 1.0;
(8, p, Some(adjust::hue_range_tables(ranges).iter().map(|t| to_row(t)).collect()))
} else {
(8, p, None)
}
} }
Adjustment::Vibrance { vibrance, saturation } => { Adjustment::Vibrance { vibrance, saturation } => {
p[0] = [vibrance / 100.0, saturation / 100.0, 0.0, 0.0]; p[0] = [vibrance / 100.0, saturation / 100.0, 0.0, 0.0];
@ -1299,8 +1303,9 @@ pub fn adjustment_program(adj: &Adjustment, transfer: Transfer) -> Program {
} }
(12, p, None) (12, p, None)
} }
Adjustment::GradientMap { stops, reverse } => { Adjustment::GradientMap { stops, reverse, dither } => {
p[0][0] = if *reverse { 1.0 } else { 0.0 }; p[0][0] = if *reverse { 1.0 } else { 0.0 };
p[0][1] = if *dither { 1.0 } else { 0.0 };
let s4: Vec<(f32, [f32; 4])> = stops.iter().map(|(t, c)| (*t, [c[0], c[1], c[2], 1.0])).collect(); let s4: Vec<(f32, [f32; 4])> = stops.iter().map(|(t, c)| (*t, [c[0], c[1], c[2], 1.0])).collect();
let rows = lut_rows(|r, t| if s4.is_empty() { t } else { sample_stops4(&s4, t)[r] }, 3); let rows = lut_rows(|r, t| if s4.is_empty() { t } else { sample_stops4(&s4, t)[r] }, 3);
(13, p, Some(rows)) (13, p, Some(rows))

View File

@ -2,7 +2,7 @@
//! the reference compositor within 2/255 (premultiplied). Skips when no GPU adapter exists. //! the reference compositor within 2/255 (premultiplied). Skips when no GPU adapter exists.
use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType}; use photocraft_color::{BlendMode, Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel};
use photocraft_doc::{Adjustment, Document, Fill, GradientStyle, Layer, LayerContent, LayerMask}; use photocraft_doc::{Adjustment, Document, Fill, GradientStyle, Layer, LayerContent, LayerMask};
use photocraft_geom::{Rect, Size}; use photocraft_geom::{Rect, Size};
use photocraft_gpu::{Compositor, render_to_vec}; use photocraft_gpu::{Compositor, render_to_vec};
@ -124,6 +124,13 @@ fn blend_modes() {
} }
} }
fn hue_ranges() -> [HueRange; 6] {
let mut r = HueRange::defaults();
r[0] = HueRange { hue: 20.0, saturation: -50.0, lightness: 10.0, ..HueRange::neutral(0) };
r[4] = HueRange { hue: -30.0, saturation: 40.0, lightness: -20.0, bounds: [180.0, 220.0, 260.0, 300.0] };
r
}
fn adjustments() -> Vec<Adjustment> { 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 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<_>>(); let pts = |v: &[(f32, f32)]| v.iter().map(|&(input, output)| CurvePoint { input, output }).collect::<Vec<_>>();
@ -134,13 +141,21 @@ fn adjustments() -> Vec<Adjustment> {
Adjustment::BrightnessContrast { brightness: 30.0, contrast: 40.0, legacy: false }, Adjustment::BrightnessContrast { brightness: 30.0, contrast: 40.0, legacy: false },
Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true }, Adjustment::BrightnessContrast { brightness: -20.0, contrast: -30.0, legacy: true },
Adjustment::Exposure { exposure: 0.7, offset: 0.02, gamma: 1.2 }, Adjustment::Exposure { exposure: 0.7, offset: 0.02, gamma: 1.2 },
Adjustment::Levels { master: lc(0.1, 0.9, 1.3), per_channel: [lc(0.0, 1.0, 0.8), LevelsChannel::default(), lc(0.2, 0.8, 1.0)] }, Adjustment::Levels {
master: lc(0.1, 0.9, 1.3),
per_channel: [lc(0.0, 1.0, 0.8), LevelsChannel::default(), lc(0.2, 0.8, 1.0)],
space: Default::default(),
black: LevelsChannel::default(),
},
Adjustment::Curves { Adjustment::Curves {
master: pts(&[(0.0, 0.1), (0.4, 0.6), (1.0, 0.9)]), master: pts(&[(0.0, 0.1), (0.4, 0.6), (1.0, 0.9)]),
per_channel: [pts(&[(0.0, 0.0), (0.5, 0.3), (1.0, 1.0)]), pts(&[(0.0, 0.0), (1.0, 1.0)]), pts(&[(0.0, 0.2), (1.0, 1.0)])], per_channel: [pts(&[(0.0, 0.0), (0.5, 0.3), (1.0, 1.0)]), pts(&[(0.0, 0.0), (1.0, 1.0)]), pts(&[(0.0, 0.2), (1.0, 1.0)])],
space: Default::default(),
black: Vec::new(),
}, },
Adjustment::HueSaturation { hue: 40.0, saturation: 30.0, lightness: -10.0, colorize: false }, Adjustment::HueSaturation { hue: 40.0, saturation: 30.0, lightness: -10.0, colorize: false, ranges: HueRange::defaults() },
Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 20.0, colorize: true }, Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 20.0, colorize: true, ranges: HueRange::defaults() },
Adjustment::HueSaturation { hue: -10.0, saturation: 10.0, lightness: 5.0, colorize: false, ranges: hue_ranges() },
Adjustment::Vibrance { vibrance: 50.0, saturation: -20.0 }, Adjustment::Vibrance { vibrance: 50.0, saturation: -20.0 },
Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false }, Adjustment::ChannelMixer { matrix: [[0.5, 0.3, 0.2, 0.0], [0.1, 0.8, 0.1, 0.05], [0.0, 0.2, 0.9, -0.05]], monochrome: false },
Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.0], [0.0; 4], [0.0; 4]], monochrome: true }, Adjustment::ChannelMixer { matrix: [[0.4, 0.4, 0.2, 0.0], [0.0; 4], [0.0; 4]], monochrome: true },
@ -148,8 +163,8 @@ fn adjustments() -> Vec<Adjustment> {
Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false }, Adjustment::PhotoFilter { color: [0.2, 0.6, 0.9], density: 0.3, preserve_luminosity: false },
Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None }, Adjustment::BlackWhite { weights: [40.0, 60.0, 40.0, 60.0, 20.0, 80.0], tint: None },
Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) }, Adjustment::BlackWhite { weights: [70.0, 20.0, 50.0, 10.0, 90.0, 30.0], tint: Some([0.9, 0.7, 0.5]) },
Adjustment::GradientMap { stops: vec![(0.0, [0.1, 0.0, 0.3]), (0.5, [0.9, 0.3, 0.1]), (1.0, [1.0, 1.0, 0.8])], reverse: false }, Adjustment::GradientMap { stops: vec![(0.0, [0.1, 0.0, 0.3]), (0.5, [0.9, 0.3, 0.1]), (1.0, [1.0, 1.0, 0.8])], reverse: false, dither: false },
Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true }, Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true, dither: true },
Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -20.0], preserve_luminosity: true }, Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -20.0], preserve_luminosity: true },
Adjustment::ColorBalance { shadows: [10.0, 0.0, 0.0], midtones: [0.0, 0.0, 0.0], highlights: [0.0, 0.0, 10.0], preserve_luminosity: false }, Adjustment::ColorBalance { shadows: [10.0, 0.0, 0.0], midtones: [0.0, 0.0, 0.0], highlights: [0.0, 0.0, 10.0], preserve_luminosity: false },
Adjustment::SelectiveColor { relative: true, adjustments: selective() }, Adjustment::SelectiveColor { relative: true, adjustments: selective() },
@ -241,7 +256,10 @@ fn groups_masks_and_clipping() {
let mut c1 = child(17, BlendMode::Multiply); let mut c1 = child(17, BlendMode::Multiply);
c1.clipped = true; c1.clipped = true;
c1.opacity = 0.6; c1.opacity = 0.6;
let mut c2 = Layer::new("hs", LayerContent::Adjustment(Adjustment::HueSaturation { hue: 90.0, saturation: 20.0, lightness: 0.0, colorize: false })); let mut c2 = Layer::new(
"hs",
LayerContent::Adjustment(Adjustment::HueSaturation { hue: 90.0, saturation: 20.0, lightness: 0.0, colorize: false, ranges: HueRange::defaults() }),
);
c2.clipped = true; c2.clipped = true;
d.layers.extend([base, c1, c2]); d.layers.extend([base, c1, c2]);
check(&mut g, &d, &format!("clipping hidden={hidden}")); check(&mut g, &d, &format!("clipping hidden={hidden}"));

View File

@ -11,7 +11,7 @@
//! stay [`Adjustment::Unsupported`] and are written back verbatim. //! stay [`Adjustment::Unsupported`] and are written back verbatim.
use photocraft_doc::Adjustment; use photocraft_doc::Adjustment;
use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; use photocraft_doc::adjust::{CurvePoint, HueRange, LevelsChannel, ToneSpace};
use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor};
/// All PSD adjustment keys recognized as adjustment layers. /// All PSD adjustment keys recognized as adjustment layers.
@ -51,7 +51,7 @@ fn parse_curves(d: &[u8]) -> Option<Adjustment> {
let bits = u32::from_be_bytes(d.get(3..7)?.try_into().ok()?); let bits = u32::from_be_bytes(d.get(3..7)?.try_into().ok()?);
let mut at = 7; let mut at = 7;
let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }]; let line = || vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }];
let mut curves: Vec<Vec<CurvePoint>> = vec![line(), line(), line(), line()]; let mut curves: Vec<Vec<CurvePoint>> = vec![line(), line(), line(), line(), line()];
for bit in 0..32usize { for bit in 0..32usize {
if bits & (1 << bit) == 0 { if bits & (1 << bit) == 0 {
continue; continue;
@ -71,8 +71,8 @@ fn parse_curves(d: &[u8]) -> Option<Adjustment> {
} }
let mut it = curves.into_iter(); let mut it = curves.into_iter();
let master = it.next()?; let master = it.next()?;
let (r, g, b) = (it.next()?, it.next()?, it.next()?); let (r, g, b, k) = (it.next()?, it.next()?, it.next()?, it.next()?);
Some(Adjustment::Curves { master, per_channel: [r, g, b] }) Some(Adjustment::Curves { master, per_channel: [r, g, b], space: ToneSpace::Rgb, black: k })
} }
fn desc_num(d: &Descriptor, key: &str) -> Option<f32> { fn desc_num(d: &Descriptor, key: &str) -> Option<f32> {
@ -98,7 +98,11 @@ pub enum Channels {
Rgb, Rgb,
/// Record 1 is the gray channel (applied to all three display channels). /// Record 1 is the gray channel (applied to all three display channels).
Gray, Gray,
/// Channel records do not map to display RGB (CMYK, Lab); ignored. /// Records 1..=4 are cyan, magenta, yellow, black ([`ToneSpace::Cmyk`]).
Cmyk,
/// Records 1..=3 are lightness, a, b ([`ToneSpace::Lab`]).
Lab,
/// Channel records do not map to display channels (multichannel, indexed…); ignored.
Other, Other,
} }
@ -113,7 +117,28 @@ pub fn parse(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>, channels: Channels
} }
let mut a = parse_any(key, data, cged); let mut a = parse_any(key, data, cged);
match (&mut a, channels) { match (&mut a, channels) {
(_, Channels::Rgb) => {} (Adjustment::Levels { space, black, .. }, Channels::Cmyk) | (Adjustment::Levels { space, black, .. }, Channels::Lab) => {
*space = if channels == Channels::Cmyk { ToneSpace::Cmyk } else { ToneSpace::Lab };
if channels == Channels::Lab {
*black = LevelsChannel::default();
}
}
(Adjustment::Curves { space, black, .. }, Channels::Cmyk) => {
*space = ToneSpace::Cmyk;
if photocraft_doc::adjust::is_identity_curve(black) {
black.clear();
}
}
(Adjustment::Curves { space, black, .. }, Channels::Lab) => {
*space = ToneSpace::Lab;
black.clear();
}
(Adjustment::Levels { black, .. }, _) => *black = LevelsChannel::default(),
(Adjustment::Curves { black, .. }, _) => black.clear(),
_ => {}
}
match (&mut a, channels) {
(_, Channels::Rgb | Channels::Cmyk | Channels::Lab) => {}
(Adjustment::Levels { per_channel, .. }, Channels::Gray) => { (Adjustment::Levels { per_channel, .. }, Channels::Gray) => {
let g = per_channel[0].clone(); let g = per_channel[0].clone();
*per_channel = [g.clone(), g.clone(), g]; *per_channel = [g.clone(), g.clone(), g];
@ -152,11 +177,21 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment {
b"hue2" => (|| { b"hue2" => (|| {
let colorize = *data.get(2)? != 0; let colorize = *data.get(2)? != 0;
let base = if colorize { 4 } else { 10 }; let base = if colorize { 4 } else { 10 };
// Six ranges from byte 16: four range values (degrees) then hue, saturation, lightness.
let mut ranges = HueRange::defaults();
for (i, r) in ranges.iter_mut().enumerate() {
let at = 16 + i * 14;
let v = |k: usize| bei16(data, at + 2 * k).map(f32::from);
if let (Some(a), Some(b), Some(c), Some(d), Some(h), Some(s), Some(l)) = (v(0), v(1), v(2), v(3), v(4), v(5), v(6)) {
*r = HueRange { hue: h, saturation: s, lightness: l, bounds: HueRange::canonical_bounds([a, b, c, d]) };
}
}
Some(Adjustment::HueSaturation { Some(Adjustment::HueSaturation {
hue: f32::from(bei16(data, base)?), hue: f32::from(bei16(data, base)?),
saturation: f32::from(bei16(data, base + 2)?), saturation: f32::from(bei16(data, base + 2)?),
lightness: f32::from(bei16(data, base + 4)?), lightness: f32::from(bei16(data, base + 4)?),
colorize, colorize,
ranges,
}) })
})(), })(),
b"expA" => (|| Some(Adjustment::Exposure { exposure: bef32(data, 2)?, offset: bef32(data, 6)?, gamma: bef32(data, 10)? }))(), b"expA" => (|| Some(Adjustment::Exposure { exposure: bef32(data, 2)?, offset: bef32(data, 6)?, gamma: bef32(data, 10)? }))(),
@ -165,7 +200,8 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment {
let r = levels_rec(data, 12)?; let r = levels_rec(data, 12)?;
let g = levels_rec(data, 22)?; let g = levels_rec(data, 22)?;
let b = levels_rec(data, 32)?; let b = levels_rec(data, 32)?;
Some(Adjustment::Levels { master: m, per_channel: [r, g, b] }) let k = levels_rec(data, 42).unwrap_or_default();
Some(Adjustment::Levels { master: m, per_channel: [r, g, b], space: ToneSpace::Rgb, black: k })
})(), })(),
b"curv" => parse_curves(data), b"curv" => parse_curves(data),
b"selc" => parse_selective(data), b"selc" => parse_selective(data),
@ -193,6 +229,7 @@ fn be32(d: &[u8], at: usize) -> Option<u32> {
fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> { fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
let version = be16(d, 0)?; let version = be16(d, 0)?;
let reverse = *d.get(2)? != 0; let reverse = *d.get(2)? != 0;
let dither = d.get(3).is_some_and(|b| *b != 0);
let (method, mut at) = match version { let (method, mut at) = match version {
1 => (None, 4), 1 => (None, 4),
3 => (Some(d.get(4..8)?), 8), 3 => (Some(d.get(4..8)?), 8),
@ -227,16 +264,16 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
let baked = crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::from_code(method)); let baked = crate::gradient_bake::bake(stops, &mids, smooth, crate::gradient_bake::Method::from_code(method));
let mut stops: Vec<(f32, [f32; 3])> = baked.iter().map(|(t, c)| (*t, c.to_rgb())).collect(); let mut stops: Vec<(f32, [f32; 3])> = baked.iter().map(|(t, c)| (*t, c.to_rgb())).collect();
stops.sort_by(|a, b| a.0.total_cmp(&b.0)); stops.sort_by(|a, b| a.0.total_cmp(&b.0));
Some(Adjustment::GradientMap { stops, reverse }) Some(Adjustment::GradientMap { stops, reverse, dither })
} }
/// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so /// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so
/// the stops are reproduced exactly). /// the stops are reproduced exactly).
fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool) -> Vec<u8> { fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec<u8> {
let mut v = Vec::new(); let mut v = Vec::new();
put16(&mut v, 1); put16(&mut v, 1);
v.push(u8::from(reverse)); v.push(u8::from(reverse));
v.push(0); v.push(u8::from(dither));
let name: Vec<u16> = "Custom".encode_utf16().chain(std::iter::once(0)).collect(); let name: Vec<u16> = "Custom".encode_utf16().chain(std::iter::once(0)).collect();
v.extend_from_slice(&(name.len() as u32).to_be_bytes()); v.extend_from_slice(&(name.len() as u32).to_be_bytes());
for u in name { for u in name {
@ -497,7 +534,7 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
.with("Auto", Value::Boolean(false)); .with("Auto", Value::Boolean(false));
return vec![(*b"brit", v), (*b"CgEd", VersionedDescriptor::new(d).to_bytes())]; return vec![(*b"brit", v), (*b"CgEd", VersionedDescriptor::new(d).to_bytes())];
} }
Adjustment::HueSaturation { hue, saturation, lightness, colorize } => { Adjustment::HueSaturation { hue, saturation, lightness, colorize, ranges } => {
put16(&mut v, 2); put16(&mut v, 2);
v.push(u8::from(*colorize)); v.push(u8::from(*colorize));
v.push(0); v.push(0);
@ -506,14 +543,15 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
v.extend_from_slice(&clamp_i16(*x).to_be_bytes()); v.extend_from_slice(&clamp_i16(*x).to_be_bytes());
} }
} }
// Six default hue ranges (reds, yellows, greens, cyans, blues, magentas). // Six hue ranges (reds, yellows, greens, cyans, blues, magentas): range values in
let ranges: [[u16; 4]; 6] = // 0..360, then the range's hue, saturation and lightness.
[[315, 345, 15, 45], [15, 45, 75, 105], [75, 105, 135, 165], [135, 165, 195, 225], [195, 225, 255, 285], [255, 285, 315, 345]];
for r in ranges { for r in ranges {
for x in r { for x in r.bounds {
put16(&mut v, x); put16(&mut v, x.rem_euclid(360.0).round() as u16 % 360);
}
for x in [r.hue, r.saturation, r.lightness] {
v.extend_from_slice(&clamp_i16(x).to_be_bytes());
} }
v.extend_from_slice(&[0; 6]);
} }
return vec![(*b"hue2", v)]; return vec![(*b"hue2", v)];
} }
@ -525,22 +563,25 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
v.push(1); // color space flag (spec: "1 byte") v.push(1); // color space flag (spec: "1 byte")
return vec![(*b"expA", v)]; return vec![(*b"expA", v)];
} }
Adjustment::Levels { master, per_channel } => { Adjustment::Levels { master, per_channel, space, black } => {
put16(&mut v, 2); put16(&mut v, 2);
levels_write(&mut v, master); levels_write(&mut v, master);
for c in per_channel { for c in per_channel {
levels_write(&mut v, c); levels_write(&mut v, c);
} }
for _ in 4..29 { let ident = LevelsChannel::default();
levels_write(&mut v, if *space == ToneSpace::Cmyk { black } else { &ident });
for _ in 5..29 {
levels_write(&mut v, &LevelsChannel::default()); levels_write(&mut v, &LevelsChannel::default());
} }
return vec![(*b"levl", v)]; return vec![(*b"levl", v)];
} }
Adjustment::Curves { master, per_channel } => { Adjustment::Curves { master, per_channel, space, black } => {
v.push(0); v.push(0);
put16(&mut v, 1); put16(&mut v, 1);
v.extend_from_slice(&0b1111u32.to_be_bytes()); let ink = *space == ToneSpace::Cmyk && black.len() >= 2;
for c in std::iter::once(master).chain(per_channel.iter()) { v.extend_from_slice(&(if ink { 0b11111u32 } else { 0b1111 }).to_be_bytes());
for c in std::iter::once(master).chain(per_channel.iter()).chain(ink.then_some(black)) {
put16(&mut v, c.len().min(19) as u16); put16(&mut v, c.len().min(19) as u16);
for p in c.iter().take(19) { for p in c.iter().take(19) {
put16(&mut v, q255(p.output)); put16(&mut v, q255(p.output));
@ -587,7 +628,7 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes()); v.extend_from_slice(&VersionedDescriptor::new(d).to_bytes());
return vec![(*b"clrL", v)]; return vec![(*b"clrL", v)];
} }
Adjustment::GradientMap { stops, reverse } => { Adjustment::GradientMap { stops, reverse, dither } => {
// Photoshop needs two stops; fewer are written as the gradient they render as // Photoshop needs two stops; fewer are written as the gradient they render as
// (none: black to white, i.e. the gray value itself; one: that colour throughout). // (none: black to white, i.e. the gray value itself; one: that colour throughout).
let stops = match stops.as_slice() { let stops = match stops.as_slice() {
@ -595,7 +636,7 @@ pub fn write(adj: &Adjustment) -> Vec<([u8; 4], Vec<u8>)> {
[(_, c)] => vec![(0.0, *c), (1.0, *c)], [(_, c)] => vec![(0.0, *c), (1.0, *c)],
s => s.to_vec(), s => s.to_vec(),
}; };
return vec![(*b"grdm", write_gradient_map(&stops, *reverse))]; return vec![(*b"grdm", write_gradient_map(&stops, *reverse, *dither))];
} }
Adjustment::Vibrance { vibrance, saturation } => { Adjustment::Vibrance { vibrance, saturation } => {
let d = Descriptor::new("null") let d = Descriptor::new("null")
@ -668,7 +709,7 @@ mod tests {
#[test] #[test]
fn gradient_map_v3_methods_and_smoothness() { fn gradient_map_v3_methods_and_smoothness() {
let classic = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])]; let classic = vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])];
let v1 = write_gradient_map(&classic, false); let v1 = write_gradient_map(&classic, false, false);
// Version 3 inserts the interpolation method after reverse/dither. // Version 3 inserts the interpolation method after reverse/dither.
let v3 = |m: &[u8; 4]| { let v3 = |m: &[u8; 4]| {
let mut v = v1.clone(); let mut v = v1.clone();
@ -692,10 +733,10 @@ mod tests {
assert!((p[0] - c[0]).abs() > 0.02, "perceptual differs from classic {p:?}"); assert!((p[0] - c[0]).abs() > 0.02, "perceptual differs from classic {p:?}");
// Smoothness 100 % (4096) bends a three-stop ramp. // Smoothness 100 % (4096) bends a three-stop ramp.
let three = vec![(0.0, [0.0; 3]), (0.25, [1.0, 0.0, 0.0]), (0.75, [0.0, 0.0, 1.0]), (1.0, [1.0; 3])]; let three = vec![(0.0, [0.0; 3]), (0.25, [1.0, 0.0, 0.0]), (0.75, [0.0, 0.0, 1.0]), (1.0, [1.0; 3])];
let mut smooth = write_gradient_map(&three, false); let mut smooth = write_gradient_map(&three, false, false);
let at = smooth.len() - 2 - 16 - 2 - 4 - 2 - 2 - 4 - 2 - 2 - 2; let at = smooth.len() - 2 - 16 - 2 - 4 - 2 - 2 - 4 - 2 - 2 - 2;
smooth[at..at + 2].copy_from_slice(&4096u16.to_be_bytes()); smooth[at..at + 2].copy_from_slice(&4096u16.to_be_bytes());
let (a, b) = (at_t(&write_gradient_map(&three, false), 0.4), at_t(&smooth, 0.4)); let (a, b) = (at_t(&write_gradient_map(&three, false, false), 0.4), at_t(&smooth, 0.4));
assert!((0..3).any(|k| (a[k] - b[k]).abs() > 1e-3), "{a:?} {b:?}"); assert!((0..3).any(|k| (a[k] - b[k]).abs() > 1e-3), "{a:?} {b:?}");
// Noise gradients (no colour stops) stay unsupported. // Noise gradients (no colour stops) stay unsupported.
assert!(matches!(parse(b"grdm", &[0, 1, 0, 0, 0, 0, 0, 0, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. })); assert!(matches!(parse(b"grdm", &[0, 1, 0, 0, 0, 0, 0, 0, 0, 0], None, Channels::Rgb), Adjustment::Unsupported { .. }));
@ -716,18 +757,42 @@ mod tests {
rt(Adjustment::Posterize { levels: 4 }); rt(Adjustment::Posterize { levels: 4 });
rt(Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: false }); rt(Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: false });
rt(Adjustment::BrightnessContrast { brightness: -150.0, contrast: 100.0, legacy: true }); rt(Adjustment::BrightnessContrast { brightness: -150.0, contrast: 100.0, legacy: true });
rt(Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false }); rt(Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false, ranges: HueRange::defaults() });
rt(Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 0.0, colorize: true }); rt(Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: 0.0, colorize: true, ranges: HueRange::defaults() });
let mut ranges = HueRange::defaults();
ranges[0] = HueRange { hue: 15.0, saturation: -40.0, lightness: 10.0, bounds: [-60.0, -20.0, 10.0, 30.0] };
ranges[4].saturation = 25.0;
rt(Adjustment::HueSaturation { hue: -10.0, saturation: 0.0, lightness: 0.0, colorize: false, ranges });
rt(Adjustment::Exposure { exposure: 1.5, offset: -0.01, gamma: 0.9 }); rt(Adjustment::Exposure { exposure: 1.5, offset: -0.01, gamma: 0.9 });
let lc = |a: u16, b: u16| LevelsChannel { in_black: f32::from(a) / 255.0, in_white: f32::from(b) / 255.0, gamma: 1.2, out_black: 0.0, out_white: 1.0 }; let lc = |a: u16, b: u16| LevelsChannel { in_black: f32::from(a) / 255.0, in_white: f32::from(b) / 255.0, gamma: 1.2, out_black: 0.0, out_white: 1.0 };
rt(Adjustment::Levels { master: lc(10, 240), per_channel: [lc(0, 255), lc(5, 250), lc(20, 200)] }); rt(Adjustment::Levels {
master: lc(10, 240),
per_channel: [lc(0, 255), lc(5, 250), lc(20, 200)],
space: ToneSpace::Rgb,
black: LevelsChannel::default(),
});
let pts = |v: &[(u8, u8)]| v.iter().map(|&(i, o)| CurvePoint { input: f32::from(i) / 255.0, output: f32::from(o) / 255.0 }).collect::<Vec<_>>(); let pts = |v: &[(u8, u8)]| v.iter().map(|&(i, o)| CurvePoint { input: f32::from(i) / 255.0, output: f32::from(o) / 255.0 }).collect::<Vec<_>>();
rt(Adjustment::Curves { rt(Adjustment::Curves {
master: pts(&[(0, 0), (128, 150), (255, 255)]), master: pts(&[(0, 0), (128, 150), (255, 255)]),
per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])], per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])],
space: ToneSpace::Rgb,
black: Vec::new(),
}); });
// CMYK ink curves and levels keep their black record.
let ink = Adjustment::Curves {
master: pts(&[(0, 0), (255, 255)]),
per_channel: [pts(&[(0, 0), (255, 255)]), pts(&[(0, 30), (255, 255)]), pts(&[(0, 0), (255, 255)])],
space: ToneSpace::Cmyk,
black: pts(&[(0, 0), (128, 100), (255, 255)]),
};
let b = write(&ink);
assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Cmyk), ink);
let inkl = Adjustment::Levels { master: lc(0, 255), per_channel: [lc(0, 255), lc(5, 250), lc(0, 255)], space: ToneSpace::Cmyk, black: lc(30, 255) };
let b = write(&inkl);
assert_eq!(parse(&b[0].0, &b[0].1, None, Channels::Cmyk), inkl);
rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3] }); rt(Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![1, 2, 3] });
rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true }); rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (0.5, [1.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: true, dither: false });
rt(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 0.0]), (1.0, [1.0, 1.0, 1.0])], reverse: false, dither: true });
rt(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -100.0, 100.0]) }); rt(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -100.0, 100.0]) });
rt(Adjustment::SelectiveColor { relative: false, adjustments: [[0.0; 4]; 9] }); rt(Adjustment::SelectiveColor { relative: false, adjustments: [[0.0; 4]; 9] });
rt(Adjustment::Vibrance { vibrance: 35.0, saturation: -12.0 }); rt(Adjustment::Vibrance { vibrance: 35.0, saturation: -12.0 });
@ -952,9 +1017,9 @@ mod tests {
for (stops, want) in for (stops, want) in
[(vec![], vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]), (vec![(0.3, [0.2, 0.4, 0.6])], vec![(0.0, [0.2, 0.4, 0.6]), (1.0, [0.2, 0.4, 0.6])])] [(vec![], vec![(0.0, [0.0; 3]), (1.0, [1.0; 3])]), (vec![(0.3, [0.2, 0.4, 0.6])], vec![(0.0, [0.2, 0.4, 0.6]), (1.0, [0.2, 0.4, 0.6])])]
{ {
let b = write(&Adjustment::GradientMap { stops, reverse: true }); let b = write(&Adjustment::GradientMap { stops, reverse: true, dither: false });
let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb); let back = parse(&b[0].0, &b[0].1, None, Channels::Rgb);
let Adjustment::GradientMap { stops, reverse: true } = back else { panic!("{back:?}") }; let Adjustment::GradientMap { stops, reverse: true, .. } = back else { panic!("{back:?}") };
assert_eq!(stops.len(), want.len()); assert_eq!(stops.len(), want.len());
for (s, w) in stops.iter().zip(&want) { for (s, w) in stops.iter().zip(&want) {
assert!((s.0 - w.0).abs() < 1e-6 && (0..3).all(|k| (s.1[k] - w.1[k]).abs() < 1e-4), "{s:?} vs {w:?}"); assert!((s.0 - w.0).abs() < 1e-6 && (0..3).all(|k| (s.1[k] - w.1[k]).abs() < 1e-4), "{s:?} vs {w:?}");
@ -975,7 +1040,12 @@ mod tests {
#[test] #[test]
fn non_rgb_ignores_channel_records() { fn non_rgb_ignores_channel_records() {
let lc = LevelsChannel { in_black: 0.1, ..Default::default() }; let lc = LevelsChannel { in_black: 0.1, ..Default::default() };
let a = Adjustment::Levels { master: LevelsChannel::default(), per_channel: [lc.clone(), lc.clone(), lc] }; let a = Adjustment::Levels {
master: LevelsChannel::default(),
per_channel: [lc.clone(), lc.clone(), lc],
space: ToneSpace::Rgb,
black: LevelsChannel::default(),
};
let b = write(&a); let b = write(&a);
match parse(&b[0].0, &b[0].1, None, Channels::Other) { match parse(&b[0].0, &b[0].1, None, Channels::Other) {
Adjustment::Levels { per_channel, .. } => assert_eq!(per_channel, <[LevelsChannel; 3]>::default()), Adjustment::Levels { per_channel, .. } => assert_eq!(per_channel, <[LevelsChannel; 3]>::default()),
@ -986,7 +1056,12 @@ mod tests {
#[test] #[test]
fn gray_uses_first_channel_record_for_all() { fn gray_uses_first_channel_record_for_all() {
let lc = LevelsChannel { in_black: 44.0 / 255.0, ..Default::default() }; let lc = LevelsChannel { in_black: 44.0 / 255.0, ..Default::default() };
let a = Adjustment::Levels { master: LevelsChannel::default(), per_channel: [lc.clone(), Default::default(), Default::default()] }; let a = Adjustment::Levels {
master: LevelsChannel::default(),
per_channel: [lc.clone(), Default::default(), Default::default()],
space: ToneSpace::Rgb,
black: LevelsChannel::default(),
};
let b = write(&a); let b = write(&a);
match parse(&b[0].0, &b[0].1, None, Channels::Gray) { match parse(&b[0].0, &b[0].1, None, Channels::Gray) {
Adjustment::Levels { per_channel, .. } => assert_eq!(per_channel, [lc.clone(), lc.clone(), lc]), Adjustment::Levels { per_channel, .. } => assert_eq!(per_channel, [lc.clone(), lc.clone(), lc]),

View File

@ -231,6 +231,8 @@ impl Ex {
let channels = match self.fmt.mode { let channels = match self.fmt.mode {
ColorMode::Rgb => adjust_map::Channels::Rgb, ColorMode::Rgb => adjust_map::Channels::Rgb,
ColorMode::Grayscale => adjust_map::Channels::Gray, ColorMode::Grayscale => adjust_map::Channels::Gray,
ColorMode::Cmyk => adjust_map::Channels::Cmyk,
ColorMode::Lab => adjust_map::Channels::Lab,
_ => adjust_map::Channels::Other, _ => adjust_map::Channels::Other,
}; };
let cged = raw.iter().find(|(k, _)| k == b"CgEd").map(|(_, d)| d.clone()); let cged = raw.iter().find(|(k, _)| k == b"CgEd").map(|(_, d)| d.clone());

View File

@ -213,6 +213,8 @@ impl Ctx<'_> {
match self.fmt.mode { match self.fmt.mode {
ColorMode::Rgb => adjust_map::Channels::Rgb, ColorMode::Rgb => adjust_map::Channels::Rgb,
ColorMode::Grayscale => adjust_map::Channels::Gray, ColorMode::Grayscale => adjust_map::Channels::Gray,
ColorMode::Cmyk => adjust_map::Channels::Cmyk,
ColorMode::Lab => adjust_map::Channels::Lab,
_ => adjust_map::Channels::Other, _ => adjust_map::Channels::Other,
}, },
)) ))

View File

@ -11,6 +11,7 @@ use std::sync::Arc;
use common::*; use common::*;
use photocraft_color::{BlendMode, ColorMode, SampleType}; use photocraft_color::{BlendMode, ColorMode, SampleType};
use photocraft_doc::adjust::{CurvePoint, LevelsChannel}; use photocraft_doc::adjust::{CurvePoint, LevelsChannel};
use photocraft_doc::adjust::{HueRange, ToneSpace};
use photocraft_doc::*; use photocraft_doc::*;
use photocraft_geom::Rect; use photocraft_geom::Rect;
use photocraft_io::*; use photocraft_io::*;
@ -45,18 +46,28 @@ fn adjustments() -> Vec<(Adjustment, bool)> {
let all = vec![ let all = vec![
(Adjustment::BrightnessContrast { brightness: 30.0, contrast: -20.0, legacy: false }, true), (Adjustment::BrightnessContrast { brightness: 30.0, contrast: -20.0, legacy: false }, true),
(Adjustment::BrightnessContrast { brightness: -40.0, contrast: 50.0, legacy: true }, true), (Adjustment::BrightnessContrast { brightness: -40.0, contrast: 50.0, legacy: true }, true),
(Adjustment::Levels { master: lc(10, 240, 1.2), per_channel: [lc(0, 255, 1.0), lc(5, 250, 0.8), lc(20, 200, 1.5)] }, true), (
Adjustment::Levels {
master: lc(10, 240, 1.2),
per_channel: [lc(0, 255, 1.0), lc(5, 250, 0.8), lc(20, 200, 1.5)],
space: ToneSpace::Rgb,
black: Default::default(),
},
true,
),
( (
Adjustment::Curves { Adjustment::Curves {
master: pts(&[(0, 0), (128, 150), (255, 255)]), master: pts(&[(0, 0), (128, 150), (255, 255)]),
per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])], per_channel: [pts(&[(0, 10), (255, 255)]), pts(&[(0, 0), (255, 245)]), pts(&[(0, 0), (64, 32), (255, 255)])],
space: ToneSpace::Rgb,
black: Vec::new(),
}, },
true, true,
), ),
(Adjustment::Exposure { exposure: 0.75, offset: -0.0125, gamma: 0.9 }, true), (Adjustment::Exposure { exposure: 0.75, offset: -0.0125, gamma: 0.9 }, true),
(Adjustment::Vibrance { vibrance: 60.0, saturation: -25.0 }, true), (Adjustment::Vibrance { vibrance: 60.0, saturation: -25.0 }, true),
(Adjustment::HueSaturation { hue: 40.0, saturation: -30.0, lightness: 10.0, colorize: false }, true), (Adjustment::HueSaturation { hue: 40.0, saturation: -30.0, lightness: 10.0, colorize: false, ranges: HueRange::defaults() }, true),
(Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: -10.0, colorize: true }, true), (Adjustment::HueSaturation { hue: 200.0, saturation: 50.0, lightness: -10.0, colorize: true, ranges: HueRange::defaults() }, true),
( (
Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -20.0], preserve_luminosity: true }, Adjustment::ColorBalance { shadows: [20.0, -10.0, 5.0], midtones: [-15.0, 10.0, 30.0], highlights: [0.0, 5.0, -20.0], preserve_luminosity: true },
true, true,
@ -74,11 +85,18 @@ fn adjustments() -> Vec<(Adjustment, bool)> {
(Adjustment::Invert, true), (Adjustment::Invert, true),
(Adjustment::Posterize { levels: 4 }, true), (Adjustment::Posterize { levels: 4 }, true),
(Adjustment::Threshold { level: g(140) }, true), (Adjustment::Threshold { level: g(140) }, true),
(Adjustment::GradientMap { stops: vec![(0.0, [q16(6554), 0.0, q16(19661)]), (0.5, [1.0, 0.2, 0.0]), (1.0, [1.0, 1.0, 0.8])], reverse: false }, true), (
(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])], reverse: true }, true), Adjustment::GradientMap {
stops: vec![(0.0, [q16(6554), 0.0, q16(19661)]), (0.5, [1.0, 0.2, 0.0]), (1.0, [1.0, 1.0, 0.8])],
reverse: false,
dither: false,
},
true,
),
(Adjustment::GradientMap { stops: vec![(0.0, [0.0, 0.0, 1.0]), (1.0, [1.0, 1.0, 0.0])], reverse: true, dither: false }, true),
// Fewer than two stops: written as the equivalent two-stop gradient. // Fewer than two stops: written as the equivalent two-stop gradient.
(Adjustment::GradientMap { stops: vec![], reverse: false }, false), (Adjustment::GradientMap { stops: vec![], reverse: false, dither: false }, false),
(Adjustment::GradientMap { stops: vec![(0.4, [0.2, 0.6, 0.4])], reverse: false }, false), (Adjustment::GradientMap { stops: vec![(0.4, [0.2, 0.6, 0.4])], reverse: false, dither: false }, false),
(Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -30.0, 20.0]) }, true), (Adjustment::SelectiveColor { relative: true, adjustments: std::array::from_fn(|r| [r as f32 * 10.0 - 40.0, 5.0, -30.0, 20.0]) }, true),
(Adjustment::SelectiveColor { relative: false, adjustments: std::array::from_fn(|r| [0.0, r as f32 * 5.0, 0.0, -10.0]) }, true), (Adjustment::SelectiveColor { relative: false, adjustments: std::array::from_fn(|r| [0.0, r as f32 * 5.0, 0.0, -10.0]) }, true),
// A block this version cannot read (a truncated Selective Color) is kept verbatim. // A block this version cannot read (a truncated Selective Color) is kept verbatim.

View File

@ -145,11 +145,13 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
Adjustment::Threshold { level: g(100) }, Adjustment::Threshold { level: g(100) },
Adjustment::Posterize { levels: 5 }, Adjustment::Posterize { levels: 5 },
Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: false }, Adjustment::BrightnessContrast { brightness: 20.0, contrast: -10.0, legacy: false },
Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false }, Adjustment::HueSaturation { hue: 30.0, saturation: -20.0, lightness: 5.0, colorize: false, ranges: photocraft_doc::adjust::HueRange::defaults() },
Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 }, Adjustment::Exposure { exposure: 0.5, offset: 0.0, gamma: 1.0 },
Adjustment::Levels { Adjustment::Levels {
master: LevelsChannel { in_black: g(10), in_white: g(240), gamma: 1.2, out_black: 0.0, out_white: 1.0 }, master: LevelsChannel { in_black: g(10), in_white: g(240), gamma: 1.2, out_black: 0.0, out_white: 1.0 },
per_channel: Default::default(), per_channel: Default::default(),
space: tone_space(mode),
black: Default::default(),
}, },
Adjustment::Curves { Adjustment::Curves {
master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: g(128), output: g(150) }, CurvePoint { input: 1.0, output: 1.0 }], master: vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: g(128), output: g(150) }, CurvePoint { input: 1.0, output: 1.0 }],
@ -158,6 +160,8 @@ pub fn gen_doc(mode: ColorMode, depth: SampleType, f: Features) -> Document {
vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }],
vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }], vec![CurvePoint { input: 0.0, output: 0.0 }, CurvePoint { input: 1.0, output: 1.0 }],
], ],
space: tone_space(mode),
black: Vec::new(),
}, },
Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] }, Adjustment::Unsupported { psd_key: "selc".into(), raw: vec![0, 1, 0, 0] },
]; ];
@ -314,3 +318,12 @@ pub fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
} }
m m
} }
/// The Levels/Curves channel space a PSD of `mode` stores (its records are the document's channels).
fn tone_space(mode: ColorMode) -> photocraft_doc::adjust::ToneSpace {
match mode {
ColorMode::Cmyk => photocraft_doc::adjust::ToneSpace::Cmyk,
ColorMode::Lab => photocraft_doc::adjust::ToneSpace::Lab,
_ => photocraft_doc::adjust::ToneSpace::Rgb,
}
}

View File

@ -171,7 +171,8 @@ fn edited_adjustment_regenerates_block_unedited_keeps_raw() {
use std::sync::Arc; use std::sync::Arc;
let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS); let mut d = gen_doc(ColorMode::Rgb, SampleType::U8, Features::PIXELS);
// Levels block with non-default extra records (record 5 changed). // Levels block with non-default extra records (record 5 changed).
let base = Adjustment::Levels { master: adjust::LevelsChannel::default(), per_channel: Default::default() }; let base =
Adjustment::Levels { master: adjust::LevelsChannel::default(), per_channel: Default::default(), space: Default::default(), black: Default::default() };
let mut l = Layer::new("lv", LayerContent::Adjustment(base.clone())); let mut l = Layer::new("lv", LayerContent::Adjustment(base.clone()));
let psd = document_to_psd(&{ let psd = document_to_psd(&{
let mut t = d.clone(); let mut t = d.clone();

View File

@ -111,7 +111,16 @@ fn synthetic(w: u32, h: u32) -> Document {
l.effects.items = effects(); l.effects.items = effects();
} }
} }
let mut adj = Layer::new("curves", LayerContent::Adjustment(Adjustment::HueSaturation { hue: 10.0, saturation: 10.0, lightness: 0.0, colorize: false })); let mut adj = Layer::new(
"curves",
LayerContent::Adjustment(Adjustment::HueSaturation {
hue: 10.0,
saturation: 10.0,
lightness: 0.0,
colorize: false,
ranges: photocraft_doc::adjust::HueRange::defaults(),
}),
);
adj.opacity = 0.9; adj.opacity = 0.9;
doc.layers.push(adj); doc.layers.push(adj);
doc doc

View File

@ -0,0 +1,170 @@
//! Modal Image › Adjustments dialogs (Levels, Curves, Hue/Saturation, …) built from the same
//! editors as the adjustment-layer Properties panel (`adjust_editors`).
//!
//! The dialog's fields are the command's complete parameter set (so automation can read and set
//! them with `ui.dialog.set`) plus private `__` keys. While it is open the canvas previews the
//! *real command* on the proxy document through the filter-preview machinery (`__filter` +
//! `__preview`); OK runs the command once (one history step) and Cancel drops the preview, leaving
//! the document untouched.
use serde_json::{Map, Value, json};
use crate::PhotocraftApp;
use crate::adjust_editors::{self, EditorCx};
use crate::tone::{self, HistSource};
const PREFIX: &str = "image.adjustments.";
/// Whether `command` opens an adjustment dialog (when run without params).
pub fn has_dialog(command: &str) -> bool {
command.strip_prefix(PREFIX).is_some_and(adjust_editors::has_editor)
}
pub fn owns(fields: &Map<String, Value>) -> bool {
fields.contains_key("__adjust")
}
/// Opens the dialog for `command` with the kind's neutral settings.
pub fn open(app: &mut PhotocraftApp, command: &str) -> Option<u64> {
let kind = command.strip_prefix(PREFIX).filter(|k| adjust_editors::has_editor(k))?;
let spec = photocraft_engine::commands::find(command)?;
let mode = app.session.active().map_or(photocraft_doc::ColorMode::Rgb, |s| s.doc.mode);
let defaults = photocraft_engine::adjust_params::default_for(kind, mode).ok()?;
let mut fields = Map::new();
fields.insert("__command".into(), json!(command));
fields.insert("__label".into(), json!(spec.label));
fields.insert("__adjust".into(), json!(kind));
fields.insert("__filter".into(), json!(true));
fields.insert("__preview".into(), json!(true));
if let Value::Object(p) = photocraft_engine::adjust_params::to_params(&defaults) {
fields.extend(p);
}
Some(app.ui.open_dialog(crate::state::DialogKind::Command, fields))
}
/// Dialog body: the kind's editor over the fields, then the Preview checkbox.
pub fn body(app: &mut PhotocraftApp, ui: &mut egui::Ui, fields: &mut Map<String, Value>) {
let kind = fields.get("__adjust").and_then(Value::as_str).unwrap_or_default().to_string();
let mut values = crate::filter_dialog::params_of(fields);
let active = app.session.active().and_then(|s| s.active_layer);
let gray = app.session.active().is_some_and(|s| adjust_editors::is_gray(s.doc.mode));
let hist = match active {
Some(id) if adjust_editors::needs_histogram(&kind) => Some(tone::histograms(app, HistSource::Layer(id), adjust_editors::space_of(&values))),
_ => None,
};
let cx = EditorCx { mem: egui::Id::new(("adjust-dialog", kind.as_str())), hist, gray, swatches: adjust_editors::swatches(app) };
let e = adjust_editors::editor(ui, &kind, &mut values, &cx);
if e.changed
&& let Value::Object(v) = values
{
// Replace the parameter keys (an editor may remove one), keep the private ones.
fields.retain(|k, _| k.starts_with("__"));
fields.extend(v);
}
let mut preview = fields.get("__preview").and_then(Value::as_bool).unwrap_or(true);
ui.add_space(6.0);
if crate::widgets::checkbox(ui, &mut preview, "Preview").changed() {
fields.insert("__preview".into(), json!(preview));
}
}
#[cfg(test)]
mod tests {
use super::*;
use egui_kittest::Harness;
fn app_with_image() -> PhotocraftApp {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 64, "height": 48})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 48})).unwrap();
s.execute("edit.fill", json!({"color": "#b04020"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
PhotocraftApp::new(s, crate::Services::default())
}
#[test]
fn every_adjustment_with_an_editor_has_a_dialog() {
for kind in adjust_editors::KINDS {
assert!(has_dialog(&format!("image.adjustments.{kind}")), "{kind}");
}
for id in ["image.adjustments.invert", "image.adjustments.selectiveColor", "image.adjustments.desaturate", "filter.blur.gaussianBlur"] {
assert!(!has_dialog(id), "{id}");
}
}
/// Opens each dialog, renders it, changes a value, then cancels (document untouched) or
/// confirms (one history step).
#[test]
fn dialogs_preview_cancel_and_commit() {
for kind in adjust_editors::KINDS {
let cmd = format!("image.adjustments.{kind}");
let mut harness =
Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]);
let id = open(harness.state_mut(), &cmd).unwrap();
harness.run_steps(3);
let before = harness.state().session.active().unwrap().revision;
let steps = harness.state().session.active().unwrap().history.past_len();
// Change the settings the way automation does.
let sample = photocraft_engine::adjust_params::to_params(
&photocraft_engine::adjust_params::from_params(kind, &sample_params(kind), None, photocraft_doc::ColorMode::Rgb).unwrap(),
);
let d = harness.state_mut().ui.dialog_mut(id).unwrap();
if let Value::Object(p) = sample {
d.fields.extend(p);
}
harness.run_steps(2);
// The preview runs the real command on the proxy.
let fields = harness.state().ui.dialogs[0].fields.clone();
let doc = harness.state().session.active().unwrap().doc.clone();
let prev = crate::filter_dialog::preview_document(
&doc,
harness.state().session.active().unwrap().active_layer,
&cmd,
&crate::filter_dialog::params_of(&fields),
1,
);
assert!(prev.is_some(), "{kind}: preview");
assert_eq!(harness.state().session.active().unwrap().revision, before, "{kind}: previewing leaves the document alone");
let r = crate::dialogs::confirm(harness.state_mut(), id);
assert!(r.is_ok(), "{kind}: {r:?}");
assert_eq!(harness.state().session.active().unwrap().history.past_len(), steps + 1, "{kind}: one history step");
}
}
fn sample_params(kind: &str) -> Value {
match kind {
"brightnessContrast" => json!({"brightness": 40}),
"levels" => json!({"inBlack": 30, "gamma": 1.3}),
"curves" => json!({"points": [[0, 0], [100, 150], [255, 255]]}),
"exposure" => json!({"exposure": 1}),
"vibrance" => json!({"vibrance": 50}),
"hueSaturation" => json!({"reds": {"hue": 40}}),
"colorBalance" => json!({"midtones": [40, 0, -20]}),
"blackWhite" => json!({"reds": 150, "tint": true}),
"photoFilter" => json!({"filter": "cooling80", "density": 60}),
"channelMixer" => json!({"red": [50, 50, 0, 0]}),
"posterize" => json!({"levels": 3}),
"threshold" => json!({"level": 90}),
"gradientMap" => json!({"stops": [[0, "#200040"], [1, "#ffd080"]]}),
_ => json!({}),
}
}
#[test]
fn cancel_leaves_the_document_untouched() {
let mut harness =
Harness::builder().with_size(egui::vec2(1200.0, 900.0)).build_ui_state(|ui, app| crate::dialogs::show(app, ui.ctx()), app_with_image());
PhotocraftApp::setup_context(&harness.ctx, crate::theme::ThemeKind::ALL[0]);
let id = open(harness.state_mut(), "image.adjustments.curves").unwrap();
harness.run_steps(2);
harness.state_mut().ui.dialog_mut(id).unwrap().fields.insert("points".into(), json!([[0, 255], [255, 0]]));
harness.run_steps(2);
let before = harness.state().session.active().unwrap().doc.clone();
harness.state_mut().ui.close_dialog(id);
harness.run_steps(2);
let after = harness.state().session.active().unwrap().doc.clone();
assert!(std::sync::Arc::ptr_eq(&before, &after));
assert!(harness.state().ui.dialogs.is_empty());
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,207 @@
//! UI tests for the adjustment editors: the whole app (eframe harness, CPU canvas) with an
//! adjustment layer on a new document, edited through the Properties panel with real pointer and
//! key input (issue #12: "add an adjustment layer to a new document, edit it, crash"), and the
//! Curves point interactions (issue #43).
use egui::{Pos2, Rect, vec2};
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use photocraft_doc::{Adjustment, LayerContent, LayerId};
use serde_json::json;
use crate::PhotocraftApp;
const ALL_KINDS: [&str; 16] = [
"brightnessContrast",
"levels",
"curves",
"exposure",
"vibrance",
"hueSaturation",
"colorBalance",
"blackWhite",
"photoFilter",
"channelMixer",
"invert",
"posterize",
"threshold",
"gradientMap",
"selectiveColor",
"colorLookup",
];
fn app_harness(kind: &str, mode: &str, depth: u32) -> Harness<'static, PhotocraftApp> {
let (kind, mode) = (kind.to_string(), mode.to_string());
let mut h = Harness::builder().with_size(vec2(1440.0, 900.0)).with_max_steps(64).build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 96, "height": 64, "mode": mode, "depth": depth})).unwrap();
// Two tones so histograms (and Levels' Auto) have something to work with.
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 48, "height": 64})).unwrap();
s.execute("edit.fill", json!({"color": "#6a3020"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute(&format!("layer.newAdjustmentLayer.{kind}"), json!({})).unwrap();
PhotocraftApp::new(s, crate::Services::default())
});
h.run_steps(8);
h
}
fn layer(h: &Harness<'_, PhotocraftApp>) -> (LayerId, Adjustment) {
let st = h.state().session.active().unwrap();
let id = st.active_layer.unwrap();
match &st.doc.layer(id).unwrap().content {
LayerContent::Adjustment(a) => (id, a.clone()),
other => panic!("{other:?}"),
}
}
fn drag(h: &mut Harness<'_, PhotocraftApp>, from: Pos2, to: Pos2) {
h.hover_at(from);
h.run_steps(1);
h.drag_at(from);
h.run_steps(1);
for i in 1..=6 {
h.hover_at(from + (to - from) * (i as f32 / 6.0));
h.run_steps(1);
}
h.drop_at(to);
h.run_steps(3);
}
fn click(h: &mut Harness<'_, PhotocraftApp>, at: Pos2) {
h.hover_at(at);
h.run_steps(1);
h.drag_at(at);
h.run_steps(1);
h.drop_at(at);
h.run_steps(3);
}
fn label_rect(h: &Harness<'_, PhotocraftApp>, text: &str) -> Option<Rect> {
// The last match: the Properties header repeats the kind's name above its controls.
h.query_all_by_label(text).last().map(|n| n.rect())
}
fn curves_graph(h: &Harness<'_, PhotocraftApp>, id: LayerId) -> Rect {
h.ctx.data(|d| d.get_temp::<Rect>(egui::Id::new(("adjust-layer", id.0)).with("curves-graph"))).expect("curves graph drawn")
}
/// Issue #12: every adjustment kind, on a new document at 8/16/32 bits and in RGB, Grayscale,
/// CMYK and Lab, opens its Properties editor and takes an edit through real input without
/// panicking; slider kinds commit exactly one history step per gesture.
#[test]
fn every_adjustment_layer_edits_through_properties() {
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("gray", 8), ("cmyk", 8), ("lab", 16)] {
for kind in ALL_KINDS {
let mut h = app_harness(kind, mode, depth);
let (id, before) = layer(&h);
let steps = h.state().session.active().unwrap().history.past_len();
// A probe label per editor; the control sits just below it.
let probe = match kind {
"brightnessContrast" => Some("Brightness"),
"exposure" => Some("Exposure"),
"vibrance" => Some("Vibrance"),
"hueSaturation" => Some("Hue"),
"colorBalance" => Some("Cyan · Red"),
"blackWhite" => Some("Reds"),
"photoFilter" => Some("Density"),
"channelMixer" => Some("Constant"),
"posterize" => Some("Levels"),
"threshold" => Some("Threshold Level"),
"selectiveColor" => Some("Cyan"),
_ => None,
};
match kind {
"curves" => {
let g = curves_graph(&h, id);
drag(&mut h, g.center(), g.center() + vec2(0.0, -40.0));
}
"levels" => {
// The output black handle sits below the output gradient bar.
let r = label_rect(&h, "Output Levels:").unwrap_or_else(|| panic!("{kind} {mode}: Output Levels"));
let y = r.bottom() + 17.0;
drag(&mut h, Pos2::new(r.left() + 6.0, y), Pos2::new(r.left() + 60.0, y));
}
"gradientMap" => {
let r = label_rect(&h, "Reverse").unwrap_or_else(|| panic!("{kind} {mode}: Reverse"));
click(&mut h, r.center());
}
_ => {
if let Some(p) = probe {
let r = label_rect(&h, p).unwrap_or_else(|| panic!("{kind} {mode}: no `{p}` label"));
let y = r.bottom() + 14.0;
drag(&mut h, Pos2::new(r.left() + 40.0, y), Pos2::new(r.left() + 120.0, y));
}
}
}
h.run_steps(4);
let (_, after) = layer(&h);
assert!(h.state().live_adjust.is_none(), "{kind} {mode}: preview ended with the gesture");
if !matches!(kind, "invert" | "colorLookup") {
assert_ne!(after, before, "{kind} {mode}@{depth}: the edit reached the layer");
assert_eq!(h.state().session.active().unwrap().history.past_len(), steps + 1, "{kind} {mode}: one history step");
}
}
}
}
fn curve_of(a: &Adjustment) -> Vec<[f32; 2]> {
match a {
Adjustment::Curves { master, .. } => master.iter().map(|p| [(p.input * 255.0).round(), (p.output * 255.0).round()]).collect(),
other => panic!("{other:?}"),
}
}
/// Issue #43: pressing on a point selects it (no new point), Delete/Backspace and ⌘/Ctrl-click
/// remove points, dragging off the graph removes one, endpoints stay.
#[test]
fn curves_points_are_added_selected_and_deleted() {
let mut h = app_harness("curves", "rgb", 8);
let (id, _) = layer(&h);
let g = curves_graph(&h, id);
let at = |v: [f32; 2]| Pos2::new(g.left() + v[0] / 255.0 * g.width(), g.bottom() - v[1] / 255.0 * g.height());
// Add a point.
click(&mut h, at([128.0, 128.0]));
assert_eq!(curve_of(&layer(&h).1).len(), 3);
// Press 4 px off it and drag: moves that point, adds none.
drag(&mut h, at([128.0, 128.0]) + vec2(4.0, 3.0), at([128.0, 180.0]));
let c = curve_of(&layer(&h).1);
assert_eq!(c.len(), 3, "{c:?}");
assert!(c[1][1] > 165.0, "{c:?}");
// Delete key removes the selected point (the graph has focus).
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(curve_of(&layer(&h).1).len(), 2);
// Backspace too.
click(&mut h, at([64.0, 100.0]));
assert_eq!(curve_of(&layer(&h).1).len(), 3);
h.key_press(egui::Key::Backspace);
h.run_steps(3);
assert_eq!(curve_of(&layer(&h).1).len(), 2);
// ⌘/Ctrl-click removes.
click(&mut h, at([190.0, 120.0]));
assert_eq!(curve_of(&layer(&h).1).len(), 3);
let p = at([190.0, 120.0]);
h.event_modifiers(egui::Event::PointerMoved(p), egui::Modifiers::COMMAND);
h.event_modifiers(
egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::COMMAND },
egui::Modifiers::COMMAND,
);
h.run_steps(1);
h.event_modifiers(
egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::COMMAND },
egui::Modifiers::COMMAND,
);
h.run_steps(3);
assert_eq!(curve_of(&layer(&h).1).len(), 2);
// Dragging a point off the graph removes it; endpoints survive both.
click(&mut h, at([100.0, 60.0]));
drag(&mut h, at([100.0, 60.0]), g.right_center() + vec2(80.0, 0.0));
assert_eq!(curve_of(&layer(&h).1).len(), 2);
drag(&mut h, at([0.0, 0.0]), g.left_top() + vec2(-80.0, -80.0));
click(&mut h, at([0.0, 0.0]));
h.key_press(egui::Key::Delete);
h.run_steps(3);
assert_eq!(curve_of(&layer(&h).1).len(), 2, "endpoints can't be deleted");
}

View File

@ -204,9 +204,10 @@ fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Arc<Document>
&& let LayerContent::Adjustment(a) = &l.content && let LayerContent::Adjustment(a) = &l.content
{ {
let kind = photocraft_engine::commands::adjustment_kind(a); let kind = photocraft_engine::commands::adjustment_kind(a);
let preview = photocraft_engine::adjust_params::from_params(kind, params, Some(a), st.doc.mode).unwrap_or_else(|_| a.clone());
let mut doc = (*st.doc).clone(); let mut doc = (*st.doc).clone();
if let Some(lm) = doc.layer_mut(*layer) { if let Some(lm) = doc.layer_mut(*layer) {
lm.content = LayerContent::Adjustment(photocraft_engine::commands::adjustment_from_params(kind, params)); lm.content = LayerContent::Adjustment(preview);
} }
let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64)); let key = 1 + params.to_string().bytes().fold(0u64, |h, b| h.wrapping_mul(31).wrapping_add(b as u64));
return (std::sync::Arc::new(doc), key); return (std::sync::Arc::new(doc), key);
@ -423,9 +424,10 @@ fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64
let l = proxy.layer(layer)?; let l = proxy.layer(layer)?;
let LayerContent::Adjustment(a) = &l.content else { return None }; let LayerContent::Adjustment(a) = &l.content else { return None };
let kind = photocraft_engine::commands::adjustment_kind(a); let kind = photocraft_engine::commands::adjustment_kind(a);
let preview = photocraft_engine::adjust_params::from_params(kind, &params, Some(a), proxy.mode).unwrap_or_else(|_| a.clone());
let mut p = (*proxy).clone(); let mut p = (*proxy).clone();
if let Some(lm) = p.layer_mut(layer) { if let Some(lm) = p.layer_mut(layer) {
lm.content = LayerContent::Adjustment(photocraft_engine::commands::adjustment_from_params(kind, &params)); lm.content = LayerContent::Adjustment(preview);
} }
let t0 = crate::gpu_canvas::now_ms(); let t0 = crate::gpu_canvas::now_ms();
let buf = photocraft_compose::flatten(&p); let buf = photocraft_compose::flatten(&p);

View File

@ -96,9 +96,10 @@ pub fn show(app: &mut PhotocraftApp, ctx: &egui::Context) {
DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields), DialogKind::Command if crate::prefs_ui::owns(&fields) => crate::prefs_ui::body(app, ui, &mut fields),
DialogKind::Command if fields.contains_key("__export") => crate::export_dialog::body(app, ui, &mut fields), DialogKind::Command if fields.contains_key("__export") => crate::export_dialog::body(app, ui, &mut fields),
DialogKind::Command if fields.contains_key("__sizing") => crate::sizing::body(ui, &mut fields), DialogKind::Command if fields.contains_key("__sizing") => crate::sizing::body(ui, &mut fields),
DialogKind::Command if crate::adjust_dialog::owns(&fields) => crate::adjust_dialog::body(app, ui, &mut fields),
DialogKind::Command if fields.contains_key("__filter") => crate::filter_dialog::body(ui, &mut fields), DialogKind::Command if fields.contains_key("__filter") => crate::filter_dialog::body(ui, &mut fields),
DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields), DialogKind::Command if fields.contains_key("__form") => crate::view_cmds::form_body(ui, &mut fields),
DialogKind::Command => command_fields(ui, &mut fields), DialogKind::Command => {}
DialogKind::LayerStyle => crate::layer_style::body(ui, &mut fields), DialogKind::LayerStyle => crate::layer_style::body(ui, &mut fields),
DialogKind::Error => { DialogKind::Error => {
ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error")); ui.label(fields.get("message").and_then(Value::as_str).unwrap_or("Error"));
@ -198,32 +199,20 @@ pub fn confirm(app: &mut PhotocraftApp, id: u64) -> Result<Value, String> {
} }
} }
fn command_fields(ui: &mut egui::Ui, f: &mut serde_json::Map<String, Value>) { /// Open the parameter dialog of `command`: the adjustment editor for `image.adjustments.*`, the
let kind = f.get("__kind").and_then(Value::as_str).unwrap_or_default().to_string(); /// schema dialog for filters, otherwise a bare confirm dialog.
for &(key, label, min, max, default) in crate::panels::adjustment_sliders(&kind) {
let mut v = f.get(key).and_then(Value::as_f64).unwrap_or(default as f64) as f32;
if ui.add(egui::Slider::new(&mut v, min..=max).text(label)).changed() {
f.insert(key.into(), json!(v));
}
}
if kind == "hueSaturation" {
let mut c = f.get("colorize").and_then(Value::as_bool).unwrap_or(false);
if ui.checkbox(&mut c, "Colorize").changed() {
f.insert("colorize".into(), json!(c));
}
}
}
/// Open a parameter dialog for a destructive `image.adjustments.*` command.
pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 { pub fn open_command_dialog(app: &mut PhotocraftApp, command: &str, label: &str) -> u64 {
let kind = command.rsplit('.').next().unwrap_or_default(); if let Some(id) = crate::adjust_dialog::open(app, command) {
return id;
}
if crate::filter_dialog::has_dialog(command)
&& let Some(id) = crate::filter_dialog::open(app, command)
{
return id;
}
let mut fields = serde_json::Map::new(); let mut fields = serde_json::Map::new();
fields.insert("__command".into(), json!(command)); fields.insert("__command".into(), json!(command));
fields.insert("__label".into(), json!(label)); fields.insert("__label".into(), json!(label));
fields.insert("__kind".into(), json!(kind));
for &(key, _, _, _, default) in crate::panels::adjustment_sliders(kind) {
fields.insert(key.into(), json!(default));
}
app.ui.open_dialog(DialogKind::Command, fields) app.ui.open_dialog(DialogKind::Command, fields)
} }

View File

@ -8,6 +8,8 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod actions; pub mod actions;
pub mod adjust_dialog;
pub mod adjust_editors;
pub mod adjust_ui; pub mod adjust_ui;
pub mod analysis_ui; pub mod analysis_ui;
pub mod artboard_ui; pub mod artboard_ui;

View File

@ -359,12 +359,8 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:
fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|o| o.is_empty()) => { fl if crate::filter_dialog::has_dialog(fl) && params.as_object().is_none_or(|o| o.is_empty()) => {
Ok(json!({"dialog": crate::filter_dialog::open(app, fl)})) Ok(json!({"dialog": crate::filter_dialog::open(app, fl)}))
} }
a if a.starts_with("image.adjustments.") a if crate::adjust_dialog::has_dialog(a) && params.as_object().is_none_or(|o| o.is_empty()) => {
&& params.as_object().is_none_or(|o| o.is_empty()) Ok(json!({"dialog": crate::adjust_dialog::open(app, a).ok_or("no document")?}))
&& !crate::panels::adjustment_sliders(a.rsplit('.').next().unwrap_or("")).is_empty() =>
{
let label = photocraft_engine::commands::find(a).map(|c| c.label).unwrap_or(a);
Ok(json!({"dialog": crate::dialogs::open_command_dialog(app, a, label)}))
} }
t if t.starts_with("window.toggle.") => { t if t.starts_with("window.toggle.") => {
let p = &mut app.ui.panels; let p = &mut app.ui.panels;

View File

@ -1609,40 +1609,6 @@ fn history(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
// ----------------------------------------------------------------------------- properties // ----------------------------------------------------------------------------- properties
/// Slider spec for adjustment properties: (param key, label, min, max, default).
pub fn adjustment_sliders(kind: &str) -> &'static [(&'static str, &'static str, f32, f32, f32)] {
match kind {
"hueSaturation" => {
&[("hue", "Hue", -180.0, 180.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0), ("lightness", "Lightness", -100.0, 100.0, 0.0)]
}
"brightnessContrast" => &[("brightness", "Brightness", -150.0, 150.0, 0.0), ("contrast", "Contrast", -50.0, 100.0, 0.0)],
"exposure" => &[("exposure", "Exposure", -5.0, 5.0, 0.0), ("offset", "Offset", -0.5, 0.5, 0.0), ("gamma", "Gamma Correction", 0.1, 3.0, 1.0)],
"vibrance" => &[("vibrance", "Vibrance", -100.0, 100.0, 0.0), ("saturation", "Saturation", -100.0, 100.0, 0.0)],
"levels" => &[("inBlack", "Input Black", 0.0, 253.0, 0.0), ("gamma", "Midtones", 0.1, 9.99, 1.0), ("inWhite", "Input White", 2.0, 255.0, 255.0)],
"threshold" => &[("level", "Threshold Level", 1.0, 255.0, 128.0)],
"posterize" => &[("levels", "Levels", 2.0, 255.0, 4.0)],
"photoFilter" => &[("density", "Density", 0.0, 100.0, 25.0)],
_ => &[],
}
}
pub fn adjustment_values(a: &photocraft_doc::Adjustment) -> Value {
use photocraft_doc::Adjustment as A;
match a {
A::HueSaturation { hue, saturation, lightness, colorize } => {
json!({"hue": hue, "saturation": saturation, "lightness": lightness, "colorize": colorize})
}
A::BrightnessContrast { brightness, contrast, .. } => json!({"brightness": brightness, "contrast": contrast}),
A::Exposure { exposure, offset, gamma } => json!({"exposure": exposure, "offset": offset, "gamma": gamma}),
A::Vibrance { vibrance, saturation } => json!({"vibrance": vibrance, "saturation": saturation}),
A::Levels { master, .. } => json!({"inBlack": master.in_black * 255.0, "inWhite": master.in_white * 255.0, "gamma": master.gamma}),
A::Threshold { level } => json!({"level": level * 255.0}),
A::Posterize { levels } => json!({"levels": levels}),
A::PhotoFilter { density, .. } => json!({"density": density * 100.0}),
_ => json!({}),
}
}
/// Floating Properties card anchored to the canvas' top-right corner. /// Floating Properties card anchored to the canvas' top-right corner.
pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) { pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
// Pro (Photoshop) docks Properties; Studio floats it over the canvas. // Pro (Photoshop) docks Properties; Studio floats it over the canvas.
@ -1728,63 +1694,7 @@ pub fn properties_window(app: &mut PhotocraftApp, ctx: &egui::Context) {
} }
fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) { fn adjustment_controls(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId, adj: &photocraft_doc::Adjustment) {
let t = Tokens::get(ui.ctx()); crate::adjust_editors::layer_editor(app, ui, id, adj);
let kind = photocraft_engine::commands::adjustment_kind(adj);
let committed = adjustment_values(adj);
let mut values = match &app.live_adjust {
Some((l, v)) if *l == id => v.clone(),
_ => committed,
};
let mut commit = false;
let mut live = false;
let hue = widgets::hue_stops();
let tone = matches!(kind, "levels" | "curves");
if kind == "levels" {
crate::tone::levels_editor(app, ui, id);
} else if kind == "curves" {
crate::tone::curves_editor(app, ui, id);
}
for &(key, label, min, max, default) in if tone { &[][..] } else { adjustment_sliders(kind) } {
let mut v = values.get(key).and_then(Value::as_f64).unwrap_or(default as f64) as f32;
let gradient = if key == "hue" { Some(&hue[..]) } else { None };
let unit = match key {
"hue" => "°",
"saturation" | "lightness" | "vibrance" | "density" => "%",
_ => "",
};
let r = widgets::slider_row(ui, label, &mut v, min..=max, unit, gradient);
if r.changed() {
values[key] = json!(v);
live = true;
}
if r.drag_stopped() || (r.changed() && !r.dragged()) {
commit = true;
}
}
if kind == "hueSaturation" {
let mut c = values.get("colorize").and_then(Value::as_bool).unwrap_or(false);
if widgets::toggle(ui, &mut c, "Colorize").changed() {
values["colorize"] = json!(c);
commit = true;
}
ui.add_space(4.0);
}
if kind == "selectiveColor" {
crate::adjust_ui::selective_color_editor(app, ui, id, adj);
} else if kind == "colorLookup" {
crate::adjust_ui::color_lookup_editor(app, ui, id, adj);
} else if adjustment_sliders(kind).is_empty() && !tone && kind != "invert" {
ui.label(RichText::new("Detailed controls for this adjustment arrive in milestone M7.").color(t.text_faint));
}
if live {
app.live_adjust = Some((id, values.clone()));
}
if commit {
let mut p = values;
p["layer"] = json!(id.0);
let _ = app.run("layer.setAdjustment", p);
app.live_adjust = None;
}
ui.add_space(6.0); ui.add_space(6.0);
let layer = app.session.active().and_then(|s| s.doc.layer(id).cloned()); let layer = app.session.active().and_then(|s| s.doc.layer(id).cloned());
if let Some(l) = layer { if let Some(l) = layer {

View File

@ -217,9 +217,8 @@ pub fn handle(app: &mut PhotocraftApp, ctx: &egui::Context) {
} }
if consume(ctx, &sc) { if consume(ctx, &sc) {
if crate::menus::is_enabled(app, &id) { if crate::menus::is_enabled(app, &id) {
let r = if id.starts_with("image.adjustments.") && !crate::panels::adjustment_sliders(id.rsplit('.').next().unwrap_or("")).is_empty() { let r = if crate::adjust_dialog::has_dialog(&id) {
let label = photocraft_engine::commands::find(&id).map(|c| c.label).unwrap_or(""); crate::adjust_dialog::open(app, &id);
crate::dialogs::open_command_dialog(app, &id, label);
Ok(serde_json::Value::Null) Ok(serde_json::Value::Null)
} else { } else {
crate::menus::invoke(app, ctx, &id, json!({})) crate::menus::invoke(app, ctx, &id, json!({}))

View File

@ -1,93 +1,177 @@
//! Photoshop-style Levels and Curves editors for the Properties panel. //! Histograms for the tone editors (Levels, Curves, Threshold) and the Histogram panel.
//! //!
//! Both draw the histogram of the image *below* the adjustment (what the adjustment receives) and //! The adjustment editors themselves live in `adjust_editors`; this module computes and caches
//! commit every change as `layer.setAdjustment` with a per-gesture `coalesce` key, so a drag is one //! the histograms they draw behind their controls, in the channels of the adjustment's space
//! history step and the canvas (GPU compositor) updates live at full resolution. //! (RGB, CMYK ink brightness, Lab), and draws them.
use std::sync::Arc; use std::sync::Arc;
use egui::{Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use egui::{Color32, Rect, Sense, vec2};
use photocraft_doc::{Document, LayerContent, LayerId}; use photocraft_doc::adjust::ToneSpace;
use serde_json::{Value, json}; use photocraft_doc::{Document, Layer, LayerId};
use crate::PhotocraftApp; use crate::PhotocraftApp;
use crate::theme::Tokens; use crate::theme::Tokens;
const CHANNELS: [(&str, &str); 4] = [("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")]; const CHANNELS: [(&str, &str); 4] = [("rgb", "RGB"), ("red", "Red"), ("green", "Green"), ("blue", "Blue")];
/// Histograms (RGB composite, R, G, B) of 256 bins each. static EMPTY: [u32; 256] = [0; 256];
pub type Histograms = [[u32; 256]; 4];
/// Histogram of the document with `hide` (the adjustment being edited) switched off. /// 256-bin histograms: the composite then one per channel of the space (RGB: luminosity, R, G, B;
fn compute_histograms(doc: &Document, hide: LayerId) -> Histograms { /// CMYK: composite, C, M, Y, K as channel brightness; Lab: L, a, b). `tag` identifies what they
/// were computed from (see [`HistSource`]).
#[derive(Clone, Debug, Default)]
pub struct Histograms {
pub tag: u64,
pub ch: Vec<[u32; 256]>,
}
impl std::ops::Index<usize> for Histograms {
type Output = [u32; 256];
fn index(&self, i: usize) -> &[u32; 256] {
self.ch.get(i).unwrap_or(&EMPTY)
}
}
/// What a tone editor's histogram shows.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HistSource {
/// The image below an adjustment layer (what the adjustment receives).
BelowLayer(LayerId),
/// One layer on its own (the target of a destructive Image › Adjustments command).
Layer(LayerId),
}
fn tag(source: HistSource, space: ToneSpace) -> u64 {
let (k, id) = match source {
HistSource::BelowLayer(l) => (1u64, l.0),
HistSource::Layer(l) => (2, l.0),
};
let s = match space {
ToneSpace::Rgb => 0u64,
ToneSpace::Cmyk => 1,
ToneSpace::Lab => 2,
};
id.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ (k << 60) ^ (s << 56)
}
/// Shows only the branch of the layer tree holding `id`. Returns whether `id` is in `layers`.
fn isolate(layers: &mut [Layer], id: LayerId) -> bool {
let mut found = false;
for l in layers.iter_mut() {
let here = l.id == id || l.children_mut().is_some_and(|c| isolate(c, id));
l.visible = here;
found |= here;
}
found
}
/// Histograms of `doc` as seen by `source`, in the channels of `space`.
pub fn compute_histograms(doc: &Document, source: HistSource, space: ToneSpace) -> Histograms {
let mut d = doc.clone(); let mut d = doc.clone();
if let Some(l) = d.layer_mut(hide) { match source {
l.visible = false; HistSource::BelowLayer(hide) => {
if let Some(l) = d.layer_mut(hide) {
l.visible = false;
}
}
HistSource::Layer(id) => {
isolate(&mut d.layers, id);
}
} }
let img = photocraft_compose::thumbnail(&d, 384); let img = photocraft_compose::thumbnail(&d, 384);
let mut h = [[0u32; 256]; 4]; let n = match space {
ToneSpace::Rgb => 4,
ToneSpace::Cmyk => 5,
ToneSpace::Lab => 3,
};
let mut h = vec![[0u32; 256]; n];
let bin = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as usize;
for p in img.pixels.as_chunks::<4>().0 { for p in img.pixels.as_chunks::<4>().0 {
if p[3] == 0 { if p[3] == 0 {
continue; continue;
} }
for c in 0..3 { match space {
h[c + 1][p[c] as usize] += 1; ToneSpace::Rgb => {
for c in 0..3 {
h[c + 1][p[c] as usize] += 1;
}
h[0][((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as usize] += 1;
}
ToneSpace::Cmyk => {
let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0);
let ink = photocraft_color::convert::rgb_to_cmyk(rgb);
for (c, v) in ink.iter().enumerate() {
h[c + 1][bin(1.0 - v)] += 1;
}
h[0][bin(1.0 - ink.iter().sum::<f32>() / 4.0)] += 1;
}
ToneSpace::Lab => {
let rgb = [p[0], p[1], p[2]].map(|v| f32::from(v) / 255.0);
let l = photocraft_color::convert::srgb_to_lab(rgb);
h[0][bin(l[0] / 100.0)] += 1;
h[1][bin((l[1] + 128.0) / 255.0)] += 1;
h[2][bin((l[2] + 128.0) / 255.0)] += 1;
}
} }
h[0][((p[0] as u32 + p[1] as u32 + p[2] as u32) / 3) as usize] += 1;
} }
h Histograms { tag: tag(source, space), ch: h }
} }
/// Cached histograms for the adjustment layer; recomputed only when something other than this /// Cached histograms for a tone editor; recomputed only when something other than the editor's
/// editor changed the document (see `PhotocraftApp::tone_hist`). /// own commits changed the document (see [`keep_after_commit`]).
fn histograms(app: &mut PhotocraftApp, id: LayerId) -> Arc<Histograms> { pub fn histograms(app: &mut PhotocraftApp, source: HistSource, space: ToneSpace) -> Arc<Histograms> {
let Some(st) = app.session.active() else { return Arc::new([[0; 256]; 4]) }; let Some(st) = app.session.active() else { return Arc::new(Histograms::default()) };
let (doc_id, rev) = (st.doc.id, st.revision); let (doc_id, rev) = (st.doc.id, st.revision);
let want = tag(source, space);
let layer = match source {
HistSource::BelowLayer(l) | HistSource::Layer(l) => l,
};
if let Some((d, l, r, h)) = &app.tone_hist if let Some((d, l, r, h)) = &app.tone_hist
&& *d == doc_id && *d == doc_id
&& *l == id && *l == layer
&& *r == rev && *r == rev
&& h.tag == want
{ {
return h.clone(); return h.clone();
} }
let t0 = crate::gpu_canvas::now_ms(); let t0 = crate::gpu_canvas::now_ms();
let h = Arc::new(compute_histograms(&st.doc, id)); let h = Arc::new(compute_histograms(&st.doc, source, space));
app.perf.span("histogram", crate::gpu_canvas::now_ms() - t0); app.perf.span("histogram", crate::gpu_canvas::now_ms() - t0);
app.tone_hist = Some((doc_id, id, rev, h.clone())); app.tone_hist = Some((doc_id, layer, rev, h.clone()));
h h
} }
/// Commit adjustment params; the editor's own edits keep the histogram cache valid. /// After an editor commits its own adjustment layer, the image below it is unchanged: keep the
fn commit(app: &mut PhotocraftApp, id: LayerId, mut params: Value, gesture: &str) { /// cached histogram valid for the new revision. Call with the revision seen before the commit.
params["layer"] = json!(id.0); pub fn keep_after_commit(app: &mut PhotocraftApp, layer: LayerId, before: Option<u64>) {
params["coalesce"] = json!(format!("tone-{}-{gesture}", id.0)); let now = app.session.active().map(|s| s.revision);
let cached = app.tone_hist.as_ref().is_some_and(|(_, l, r, _)| *l == id && app.session.active().is_some_and(|s| s.revision == *r)); if let (Some((_, l, r, _)), Some(before), Some(now)) = (app.tone_hist.as_mut(), before, now)
if app.run("layer.setAdjustment", params).is_ok() && *l == layer
&& cached && *r == before
&& let (Some((_, _, r, _)), Some(st)) = (app.tone_hist.as_mut(), app.session.active())
{ {
*r = st.revision; *r = now;
} }
} }
fn draw_histogram(p: &egui::Painter, r: Rect, h: &[u32; 256], color: Color32) { pub fn draw_histogram(p: &egui::Painter, r: Rect, h: &[u32; 256], color: Color32) {
// Scale to a high percentile so a single spike (e.g. pure white) doesn't flatten the rest. // Scale to a high percentile so a single spike (e.g. pure white) doesn't flatten the rest.
let mut sorted: Vec<u32> = h.to_vec(); let mut sorted: Vec<u32> = h.to_vec();
sorted.sort_unstable(); sorted.sort_unstable();
let top = sorted[250].max(1) as f32 * 1.1; let top = sorted.get(250).copied().unwrap_or(1).max(1) as f32 * 1.1;
let mut pts = vec![pos2(r.left(), r.bottom())]; let mut pts = vec![egui::pos2(r.left(), r.bottom())];
for (i, v) in h.iter().enumerate() { for (i, v) in h.iter().enumerate() {
let x = r.left() + (i as f32 + 0.5) / 256.0 * r.width(); let x = r.left() + (i as f32 + 0.5) / 256.0 * r.width();
let y = r.bottom() - (*v as f32 / top).min(1.0) * r.height(); let y = r.bottom() - (*v as f32 / top).min(1.0) * r.height();
pts.push(pos2(x, y)); pts.push(egui::pos2(x, y));
} }
pts.push(pos2(r.right(), r.bottom())); pts.push(egui::pos2(r.right(), r.bottom()));
// One mesh of vertical strips: no anti-aliasing feather, so no seams between bins. // One mesh of vertical strips: no anti-aliasing feather, so no seams between bins.
let mut mesh = egui::Mesh::default(); let mut mesh = egui::Mesh::default();
for w in pts.windows(2).skip(1).take(256) { for w in pts.windows(2).skip(1).take(256) {
let (a, b) = (w[0], w[1]); let (a, b) = (w[0], w[1]);
let i = mesh.vertices.len() as u32; let i = mesh.vertices.len() as u32;
for q in [pos2(a.x, r.bottom()), a, b, pos2(b.x, r.bottom())] { for q in [egui::pos2(a.x, r.bottom()), a, b, egui::pos2(b.x, r.bottom())] {
mesh.colored_vertex(q, color); mesh.colored_vertex(q, color);
} }
mesh.add_triangle(i, i + 1, i + 2); mesh.add_triangle(i, i + 1, i + 2);
@ -96,11 +180,17 @@ fn draw_histogram(p: &egui::Painter, r: Rect, h: &[u32; 256], color: Color32) {
p.add(mesh); p.add(mesh);
} }
fn channel_color(ch: usize, t: &Tokens) -> Color32 { /// Display colour of channel `name` (curve, histogram and gradient bars).
match ch { pub fn channel_color(name: &str, t: &Tokens) -> Color32 {
1 => Color32::from_rgb(235, 80, 80), match name {
2 => Color32::from_rgb(80, 205, 95), "red" => Color32::from_rgb(235, 80, 80),
3 => Color32::from_rgb(90, 140, 255), "green" => Color32::from_rgb(80, 205, 95),
"blue" => Color32::from_rgb(90, 140, 255),
"cyan" => Color32::from_rgb(40, 200, 230),
"magenta" => Color32::from_rgb(225, 70, 200),
"yellow" => Color32::from_rgb(235, 210, 40),
"a" => Color32::from_rgb(220, 90, 140),
"b" => Color32::from_rgb(200, 180, 60),
_ => t.text, _ => t.text,
} }
} }
@ -134,7 +224,7 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let due = app.doc_hist.as_ref().is_none_or(|(d, _, at, _)| *d != doc_id || now - at > 250.0); let due = app.doc_hist.as_ref().is_none_or(|(d, _, at, _)| *d != doc_id || now - at > 250.0);
if stale && due { if stale && due {
let t0 = now; let t0 = now;
let h = Arc::new(compute_histograms(&st.doc, LayerId(u64::MAX))); let h = Arc::new(compute_histograms(&st.doc, HistSource::BelowLayer(LayerId(u64::MAX)), ToneSpace::Rgb));
app.perf.span("histogram", crate::gpu_canvas::now_ms() - t0); app.perf.span("histogram", crate::gpu_canvas::now_ms() - t0);
app.doc_hist = Some((doc_id, rev, now, h)); app.doc_hist = Some((doc_id, rev, now, h));
} else if stale { } else if stale {
@ -149,7 +239,8 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
let (r, _) = ui.allocate_exact_size(vec2(w, 100.0), Sense::hover()); let (r, _) = ui.allocate_exact_size(vec2(w, 100.0), Sense::hover());
let p = ui.painter_at(r); let p = ui.painter_at(r);
p.rect_filled(r, 0.0, t.field); p.rect_filled(r, 0.0, t.field);
draw_histogram(&p, r, &h[ch], if ch == 0 { Color32::from_gray(if t.pro { 190 } else { 70 }) } else { channel_color(ch, &t) }); let color = if ch == 0 { Color32::from_gray(if t.pro { 190 } else { 70 }) } else { channel_color(CHANNELS[ch.min(3)].0, &t) };
draw_histogram(&p, r, &h[ch], color);
if stale { if stale {
// Photoshop's "cached data" warning triangle while the histogram lags the image. // Photoshop's "cached data" warning triangle while the histogram lags the image.
crate::icons::paint(ui, Rect::from_min_size(r.right_top() + vec2(-18.0, 2.0), vec2(16.0, 16.0)), "triangle-alert", 12.0, t.warning); crate::icons::paint(ui, Rect::from_min_size(r.right_top() + vec2(-18.0, 2.0), vec2(16.0, 16.0)), "triangle-alert", 12.0, t.warning);
@ -182,410 +273,10 @@ pub fn histogram_panel(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
}); });
} }
// ---------------------------------------------------------------------------------------------
// Curves
/// Curve points (0–255) per channel from the adjustment: [rgb, red, green, blue].
fn curves_points(adj: &photocraft_doc::Adjustment) -> [Vec<[f32; 2]>; 4] {
let conv = |v: &[photocraft_doc::adjust::CurvePoint]| v.iter().map(|p| [p.input * 255.0, p.output * 255.0]).collect::<Vec<_>>();
match adj {
photocraft_doc::Adjustment::Curves { master, per_channel } => [conv(master), conv(&per_channel[0]), conv(&per_channel[1]), conv(&per_channel[2])],
_ => Default::default(),
}
}
fn curves_params(pts: &[Vec<[f32; 2]>; 4]) -> Value {
let arr = |v: &Vec<[f32; 2]>| json!(v.iter().map(|p| [p[0].round(), p[1].round()]).collect::<Vec<_>>());
json!({"points": arr(&pts[0]), "red": arr(&pts[1]), "green": arr(&pts[2]), "blue": arr(&pts[3])})
}
fn lut_of(points: &[[f32; 2]]) -> Vec<f32> {
let cp: Vec<photocraft_doc::adjust::CurvePoint> =
points.iter().map(|p| photocraft_doc::adjust::CurvePoint { input: p[0] / 255.0, output: p[1] / 255.0 }).collect();
photocraft_compose::adjust::curve_lut(&cp)
}
pub fn curves_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId) {
let Some(adj) = app.session.active().and_then(|s| s.doc.layer(id)).and_then(|l| match &l.content {
LayerContent::Adjustment(a) => Some(a.clone()),
_ => None,
}) else {
return;
};
let t = Tokens::get(ui.ctx());
let mem = egui::Id::new(("curves", id.0));
let ch = channel_picker(ui, mem.with("ch"), "Channel:");
let mut pts = curves_points(&adj);
let hist = histograms(app, id);
let side = ui.available_width().min(300.0);
ui.add_space(4.0);
let (full, resp) = ui.allocate_exact_size(vec2(side, side + 14.0), Sense::click_and_drag());
let graph = Rect::from_min_size(full.min + vec2(14.0, 0.0), vec2(side - 14.0, side - 14.0));
let p = ui.painter_at(full);
p.rect_filled(graph, 0.0, t.field);
draw_histogram(&p, graph, &hist[ch], Color32::from_gray(if t.pro { 88 } else { 170 }).gamma_multiply(0.75));
// Grid: quarters (Photoshop default), baseline diagonal.
for i in 1..4 {
let f = i as f32 / 4.0;
let g = Stroke::new(1.0, t.separator);
p.line_segment([pos2(graph.left() + f * graph.width(), graph.top()), pos2(graph.left() + f * graph.width(), graph.bottom())], g);
p.line_segment([pos2(graph.left(), graph.top() + f * graph.height()), pos2(graph.right(), graph.top() + f * graph.height())], g);
}
p.line_segment([graph.left_bottom(), graph.right_top()], Stroke::new(1.0, t.separator.gamma_multiply(1.6)));
p.rect_stroke(graph, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Outside);
// Gradient bars: input (bottom) and output (left).
let hbar = Rect::from_min_max(pos2(graph.left(), graph.bottom() + 4.0), pos2(graph.right(), graph.bottom() + 12.0));
let vbar = Rect::from_min_max(pos2(full.left(), graph.top()), pos2(full.left() + 8.0, graph.bottom()));
let mut mesh = egui::Mesh::default();
let (c0, c1) = (Color32::BLACK, if ch == 0 { Color32::WHITE } else { channel_color(ch, &t) });
mesh.colored_vertex(hbar.left_top(), c0);
mesh.colored_vertex(hbar.right_top(), c1);
mesh.colored_vertex(hbar.right_bottom(), c1);
mesh.colored_vertex(hbar.left_bottom(), c0);
mesh.colored_vertex(vbar.left_bottom(), c0);
mesh.colored_vertex(vbar.right_bottom(), c0);
mesh.colored_vertex(vbar.right_top(), c1);
mesh.colored_vertex(vbar.left_top(), c1);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
mesh.add_triangle(4, 5, 6);
mesh.add_triangle(4, 6, 7);
p.add(mesh);
let to_scr = |q: [f32; 2]| pos2(graph.left() + q[0] / 255.0 * graph.width(), graph.bottom() - q[1] / 255.0 * graph.height());
let to_val =
|s: Pos2| [((s.x - graph.left()) / graph.width() * 255.0).clamp(0.0, 255.0), ((graph.bottom() - s.y) / graph.height() * 255.0).clamp(0.0, 255.0)];
// Other channels' curves, faintly, when editing the composite (Photoshop shows them too).
let draw_curve = |pts: &[[f32; 2]], color: Color32, width: f32| {
let lut = lut_of(pts);
let n = lut.len();
let line: Vec<Pos2> = (0..n).step_by((n / 256).max(1)).map(|i| to_scr([i as f32 / (n - 1) as f32 * 255.0, lut[i].clamp(0.0, 1.0) * 255.0])).collect();
p.add(egui::Shape::line(line, Stroke::new(width, color)));
};
if ch == 0 {
for (c, cp) in pts.iter().enumerate().skip(1) {
if cp.len() > 2 || cp.first().is_some_and(|q| q[1] != 0.0) || cp.last().is_some_and(|q| q[1] != 255.0) {
draw_curve(cp, channel_color(c, &t).gamma_multiply(0.8), 1.0);
}
}
}
draw_curve(&pts[ch], channel_color(ch, &t), 1.5);
// Interaction.
let sel_id = mem.with("sel");
let mut sel: Option<usize> = ui.data(|d| d.get_temp(sel_id)).flatten();
let gesture_id = mem.with("gesture");
let mut changed = false;
let cur = &mut pts[ch];
if (resp.drag_started() || resp.clicked())
&& let Some(pos) = resp.interact_pointer_pos()
{
let hit =
cur.iter().enumerate().map(|(i, q)| (i, to_scr(*q).distance(pos))).filter(|(_, d)| *d < 9.0).min_by(|a, b| a.1.total_cmp(&b.1)).map(|(i, _)| i);
sel = match hit {
Some(i) => Some(i),
None if graph.contains(pos) => {
let v = to_val(pos);
let i = cur.iter().position(|q| q[0] > v[0]).unwrap_or(cur.len());
cur.insert(i, v);
changed = true;
Some(i)
}
None => sel,
};
ui.data_mut(|d| d.insert_temp(gesture_id, ui.input(|i| i.time).to_bits()));
}
if resp.dragged()
&& let (Some(i), Some(pos)) = (sel, resp.interact_pointer_pos())
&& i < cur.len()
{
let far = !graph.expand(24.0).contains(pos);
let endpoint = i == 0 || i + 1 == cur.len();
if far && !endpoint && cur.len() > 2 {
// Dragging a point out of the graph deletes it (Photoshop).
cur.remove(i);
sel = None;
} else {
let mut v = to_val(pos);
let lo = if i > 0 { cur[i - 1][0] + 1.0 } else { 0.0 };
let hi = if i + 1 < cur.len() { cur[i + 1][0] - 1.0 } else { 255.0 };
v[0] = v[0].clamp(lo, hi.max(lo));
cur[i] = v;
}
changed = true;
}
// Points.
for (i, q) in cur.iter().enumerate() {
let r = Rect::from_center_size(to_scr(*q), vec2(7.0, 7.0));
if Some(i) == sel {
p.rect_filled(r, 0.0, t.text);
p.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::BLACK), egui::StrokeKind::Outside);
} else {
p.rect_filled(r, 0.0, t.field);
p.rect_stroke(r, 0.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
}
}
// Input / Output of the selected point.
ui.add_space(6.0);
ui.horizontal(|ui| {
let i = sel.filter(|i| *i < cur.len());
let (mut vi, mut vo) = i.map_or((0.0, 0.0), |i| (cur[i][0], cur[i][1]));
ui.label(egui::RichText::new("Input:").color(t.text_dim).size(12.0));
let ri = ui.add_enabled_ui(i.is_some(), |ui| crate::widgets::value_field(ui, &mut vi, 0.0..=255.0, "", 52.0)).inner;
ui.label(egui::RichText::new("Output:").color(t.text_dim).size(12.0));
let ro = ui.add_enabled_ui(i.is_some(), |ui| crate::widgets::value_field(ui, &mut vo, 0.0..=255.0, "", 52.0)).inner;
if let Some(i) = i
&& (ri.changed() || ro.changed())
{
let lo = if i > 0 { cur[i - 1][0] + 1.0 } else { 0.0 };
let hi = if i + 1 < cur.len() { cur[i + 1][0] - 1.0 } else { 255.0 };
cur[i] = [vi.round().clamp(lo, hi.max(lo)), vo.round().clamp(0.0, 255.0)];
changed = true;
}
});
ui.data_mut(|d| d.insert_temp(sel_id, sel));
if changed {
let g: u64 = ui.data(|d| d.get_temp(gesture_id)).unwrap_or(0);
commit(app, id, curves_params(&pts), &g.to_string());
}
}
// ---------------------------------------------------------------------------------------------
// Levels
type Lv = [f32; 5]; // in_black, gamma, in_white, out_black, out_white (0–255, gamma as is)
fn levels_values(adj: &photocraft_doc::Adjustment) -> [Lv; 4] {
let conv = |c: &photocraft_doc::adjust::LevelsChannel| [c.in_black * 255.0, c.gamma, c.in_white * 255.0, c.out_black * 255.0, c.out_white * 255.0];
match adj {
photocraft_doc::Adjustment::Levels { master, per_channel } => [conv(master), conv(&per_channel[0]), conv(&per_channel[1]), conv(&per_channel[2])],
_ => [[0.0, 1.0, 255.0, 0.0, 255.0]; 4],
}
}
fn levels_params(v: &[Lv; 4]) -> Value {
let obj = |l: &Lv| json!({"inBlack": l[0].round(), "gamma": (l[1] * 100.0).round() / 100.0, "inWhite": l[2].round(), "outBlack": l[3].round(), "outWhite": l[4].round()});
let mut p = obj(&v[0]);
p["red"] = obj(&v[1]);
p["green"] = obj(&v[2]);
p["blue"] = obj(&v[3]);
p
}
/// A triangular slider handle below a bar.
fn handle(p: &egui::Painter, x: f32, y: f32, fill: Color32, stroke: Color32) {
p.add(egui::Shape::convex_polygon(vec![pos2(x, y), pos2(x + 5.5, y + 9.0), pos2(x - 5.5, y + 9.0)], fill, Stroke::new(1.0, stroke)));
}
/// Input level (0–255) where the gamma slider sits: the input that maps to 50% grey.
fn gamma_pos(black: f32, white: f32, gamma: f32) -> f32 {
black + (white - black) * 0.5f32.powf(gamma)
}
fn gamma_from_pos(black: f32, white: f32, x: f32) -> f32 {
let t = ((x - black) / (white - black).max(1.0)).clamp(0.01, 0.99);
(t.ln() / 0.5f32.ln()).clamp(0.01, 9.99)
}
pub fn levels_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, id: LayerId) {
let Some(adj) = app.session.active().and_then(|s| s.doc.layer(id)).and_then(|l| match &l.content {
LayerContent::Adjustment(a) => Some(a.clone()),
_ => None,
}) else {
return;
};
let t = Tokens::get(ui.ctx());
let mem = egui::Id::new(("levels", id.0));
let hist = histograms(app, id);
let mut ch = 0;
let mut auto = false;
ui.horizontal(|ui| {
ch = channel_picker(ui, mem.with("ch"), "");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
auto = crate::widgets::secondary_button(ui, "Auto", 0.0).clicked();
});
});
let mut v = levels_values(&adj);
let mut changed = false;
let gesture_id = mem.with("gesture");
if auto {
// Clip 0.1% at each end of the composite histogram (Photoshop's default auto clipping).
let h = &hist[if ch == 0 { 0 } else { ch }];
let total: u32 = h.iter().sum();
let clip = (total as f32 * 0.001) as u32;
let (mut acc, mut lo) = (0u32, 0usize);
while lo < 254 && acc + h[lo] <= clip {
acc += h[lo];
lo += 1;
}
let (mut acc, mut hi) = (0u32, 255usize);
while hi > lo + 1 && acc + h[hi] <= clip {
acc += h[hi];
hi -= 1;
}
v[ch][0] = lo as f32;
v[ch][2] = hi as f32;
v[ch][1] = 1.0;
changed = true;
ui.data_mut(|d| d.insert_temp(gesture_id, ui.input(|i| i.time).to_bits()));
}
let w = ui.available_width().min(300.0);
// Histogram.
let (hr, _) = ui.allocate_exact_size(vec2(w, 110.0), Sense::hover());
let hr = hr.shrink2(vec2(6.0, 0.0));
let p = ui.painter_at(hr.expand(1.0));
p.rect_filled(hr, 0.0, t.field);
draw_histogram(&p, hr, &hist[ch], if ch == 0 { Color32::from_gray(if t.pro { 180 } else { 60 }) } else { channel_color(ch, &t).gamma_multiply(0.85) });
p.rect_stroke(hr, 0.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
// Input sliders: black, gamma, white.
let (sr, sresp) = ui.allocate_exact_size(vec2(w, 14.0), Sense::click_and_drag());
let track = sr.shrink2(vec2(6.0, 0.0));
let xpos = |val: f32| track.left() + val / 255.0 * track.width();
let xval = |x: f32| ((x - track.left()) / track.width() * 255.0).clamp(0.0, 255.0);
let [b, g, wv, ob, ow] = v[ch];
let gx = gamma_pos(b, wv, g);
let drag_id = mem.with("drag");
let mut which: Option<u8> = ui.data(|d| d.get_temp(drag_id)).flatten();
if sresp.drag_started()
&& let Some(pos) = sresp.interact_pointer_pos()
{
let d = [(0u8, (xpos(b) - pos.x).abs()), (1, (xpos(gx) - pos.x).abs()), (2, (xpos(wv) - pos.x).abs())];
which = d.iter().min_by(|a, c| a.1.total_cmp(&c.1)).map(|x| x.0);
ui.data_mut(|d| d.insert_temp(gesture_id, ui.input(|i| i.time).to_bits()));
}
if sresp.dragged()
&& let (Some(k), Some(pos)) = (which, sresp.interact_pointer_pos())
{
let x = xval(pos.x);
match k {
0 => {
v[ch][0] = x.min(wv - 2.0).round();
}
2 => {
v[ch][2] = x.max(b + 2.0).round();
}
_ => v[ch][1] = gamma_from_pos(b, wv, x),
}
changed = true;
}
if sresp.drag_stopped() {
which = None;
}
ui.data_mut(|d| d.insert_temp(drag_id, which));
let sp = ui.painter_at(sr.expand(6.0));
let [b, g, wv, _, _] = v[ch];
handle(&sp, xpos(b), sr.top() + 2.0, Color32::BLACK, t.text_dim);
handle(&sp, xpos(gamma_pos(b, wv, g)), sr.top() + 2.0, Color32::from_gray(128), t.text_dim);
handle(&sp, xpos(wv), sr.top() + 2.0, Color32::WHITE, t.text_dim);
ui.horizontal(|ui| {
let fw = ((w - 16.0) / 3.0).min(70.0);
let mut nb = v[ch][0];
let mut ng = v[ch][1];
let mut nw = v[ch][2];
if crate::widgets::value_field(ui, &mut nb, 0.0..=253.0, "", fw).changed() {
v[ch][0] = nb.round().min(v[ch][2] - 2.0);
changed = true;
}
ui.add_space(((w - 3.0 * fw) / 2.0 - 12.0).max(0.0));
if crate::widgets::value_field(ui, &mut ng, 0.01..=9.99, "", fw).changed() {
v[ch][1] = ng.clamp(0.01, 9.99);
changed = true;
}
ui.add_space(((w - 3.0 * fw) / 2.0 - 12.0).max(0.0));
if crate::widgets::value_field(ui, &mut nw, 2.0..=255.0, "", fw).changed() {
v[ch][2] = nw.round().max(v[ch][0] + 2.0);
changed = true;
}
});
// Output levels.
ui.add_space(6.0);
ui.label(egui::RichText::new("Output Levels").color(t.text_dim).size(12.0));
let (ob_r, _) = ui.allocate_exact_size(vec2(w, 10.0), Sense::hover());
let ob_r = ob_r.shrink2(vec2(6.0, 0.0));
let mut mesh = egui::Mesh::default();
let c1 = if ch == 0 { Color32::WHITE } else { channel_color(ch, &t) };
mesh.colored_vertex(ob_r.left_top(), Color32::BLACK);
mesh.colored_vertex(ob_r.right_top(), c1);
mesh.colored_vertex(ob_r.right_bottom(), c1);
mesh.colored_vertex(ob_r.left_bottom(), Color32::BLACK);
mesh.add_triangle(0, 1, 2);
mesh.add_triangle(0, 2, 3);
ui.painter().add(mesh);
let (or, oresp) = ui.allocate_exact_size(vec2(w, 14.0), Sense::click_and_drag());
let otrack = or.shrink2(vec2(6.0, 0.0));
let oxpos = |val: f32| otrack.left() + val / 255.0 * otrack.width();
let oval = |x: f32| ((x - otrack.left()) / otrack.width() * 255.0).clamp(0.0, 255.0);
let odrag_id = mem.with("odrag");
let mut owhich: Option<u8> = ui.data(|d| d.get_temp(odrag_id)).flatten();
if oresp.drag_started()
&& let Some(pos) = oresp.interact_pointer_pos()
{
owhich = Some(if (oxpos(ob) - pos.x).abs() <= (oxpos(ow) - pos.x).abs() { 0 } else { 1 });
ui.data_mut(|d| d.insert_temp(gesture_id, ui.input(|i| i.time).to_bits()));
}
if oresp.dragged()
&& let (Some(k), Some(pos)) = (owhich, oresp.interact_pointer_pos())
{
v[ch][if k == 0 { 3 } else { 4 }] = oval(pos.x).round();
changed = true;
}
if oresp.drag_stopped() {
owhich = None;
}
ui.data_mut(|d| d.insert_temp(odrag_id, owhich));
let op = ui.painter_at(or.expand(6.0));
handle(&op, oxpos(v[ch][3]), or.top() + 2.0, Color32::BLACK, t.text_dim);
handle(&op, oxpos(v[ch][4]), or.top() + 2.0, Color32::WHITE, t.text_dim);
ui.horizontal(|ui| {
let fw = ((w - 16.0) / 3.0).min(70.0);
let (mut a, mut c) = (v[ch][3], v[ch][4]);
if crate::widgets::value_field(ui, &mut a, 0.0..=255.0, "", fw).changed() {
v[ch][3] = a.round();
changed = true;
}
ui.add_space((w - 2.0 * fw - 16.0).max(0.0));
if crate::widgets::value_field(ui, &mut c, 0.0..=255.0, "", fw).changed() {
v[ch][4] = c.round();
changed = true;
}
});
let _ = (g, ob, ow);
if changed {
let gk: u64 = ui.data(|d| d.get_temp(gesture_id)).unwrap_or(0);
commit(app, id, levels_params(&v), &gk.to_string());
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use serde_json::json;
#[test]
fn gamma_slider_position_round_trips() {
for g in [0.3f32, 1.0, 1.8, 4.0] {
let x = gamma_pos(20.0, 230.0, g);
assert!((gamma_from_pos(20.0, 230.0, x) - g).abs() < 1e-3, "{g}");
}
// Moving the grey slider left brightens (gamma > 1), as in Photoshop.
assert!(gamma_from_pos(0.0, 255.0, 80.0) > 1.0);
assert_eq!(gamma_pos(0.0, 255.0, 1.0), 127.5);
}
#[test]
fn params_round_trip_through_engine() {
let v: [Lv; 4] = [[10.0, 1.2, 240.0, 5.0, 250.0], [0.0, 1.0, 255.0, 0.0, 255.0], [3.0, 0.8, 200.0, 0.0, 255.0], [0.0, 1.0, 255.0, 0.0, 255.0]];
let adj = photocraft_engine::commands::adjustment_from_params("levels", &levels_params(&v));
let back = levels_values(&adj);
for (a, b) in v.iter().flatten().zip(back.iter().flatten()) {
assert!((a - b).abs() < 0.01, "{a} vs {b}");
}
let pts: [Vec<[f32; 2]>; 4] = [
vec![[0.0, 0.0], [128.0, 150.0], [255.0, 255.0]],
vec![[0.0, 10.0], [255.0, 245.0]],
vec![[0.0, 0.0], [255.0, 255.0]],
vec![[0.0, 0.0], [255.0, 255.0]],
];
let adj = photocraft_engine::commands::adjustment_from_params("curves", &curves_params(&pts));
assert_eq!(curves_points(&adj), pts);
}
#[test] #[test]
fn histogram_ignores_the_edited_adjustment() { fn histogram_ignores_the_edited_adjustment() {
@ -593,7 +284,23 @@ mod tests {
s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); // white s.execute("file.new", json!({"width": 64, "height": 64})).unwrap(); // white
s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap(); s.execute("layer.newAdjustmentLayer.invert", json!({})).unwrap();
let st = s.active().unwrap(); let st = s.active().unwrap();
let h = compute_histograms(&st.doc, st.active_layer.unwrap()); let h = compute_histograms(&st.doc, HistSource::BelowLayer(st.active_layer.unwrap()), ToneSpace::Rgb);
assert!(h[0][255] > 0 && h[0][0] == 0, "sees the white image, not the inverted result"); assert!(h[0][255] > 0 && h[0][0] == 0, "sees the white image, not the inverted result");
assert_eq!(h[9], [0; 256], "out-of-range channels are empty, not a panic");
}
#[test]
fn layer_histogram_sees_only_that_layer_and_spaces_have_their_channels() {
let mut s = photocraft_engine::Session::new();
s.execute("file.new", json!({"width": 32, "height": 32})).unwrap(); // white background
let bg = s.active().unwrap().doc.layers[0].id;
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 32, "height": 32})).unwrap();
s.execute("edit.fill", json!({"color": "#000000"})).unwrap();
let st = s.active().unwrap();
let h = compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Rgb);
assert!(h[0][255] > 0 && h[0][0] == 0, "the black layer above is hidden");
assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Cmyk).ch.len(), 5);
assert_eq!(compute_histograms(&st.doc, HistSource::Layer(bg), ToneSpace::Lab).ch.len(), 3);
} }
} }