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:
parent
7a5c1930b3
commit
37c8f81032
|
|
@ -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]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, .. }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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}"));
|
||||||
|
|
|
||||||
|
|
@ -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]),
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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");
|
||||||
|
}
|
||||||
|
|
@ -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, ¶ms, 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, ¶ms));
|
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);
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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!({}))
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue