diff --git a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs index ef8bae14b6..78e9019a62 100644 --- a/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs +++ b/editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs @@ -919,6 +919,7 @@ fn static_node_properties() -> NodeProperties { map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties)); map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties)); map.insert("vibrance_properties".to_string(), Box::new(node_properties::vibrance_properties)); + map.insert("color_balance_properties".to_string(), Box::new(node_properties::color_balance_properties)); map.insert("fill_properties".to_string(), Box::new(node_properties::fill_properties)); map.insert("stroke_properties".to_string(), Box::new(node_properties::stroke_properties)); map.insert("offset_path_properties".to_string(), Box::new(node_properties::offset_path_properties)); diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index 8719ff6578..4d8a7c451d 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -21,7 +21,7 @@ use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; use graphene_std::raster::{ AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, + SelectiveColorChoice, TonalRange, }; use graphene_std::raster_types::Image; use graphene_std::text::{Font, TextAlign}; @@ -318,6 +318,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), @@ -1676,6 +1677,51 @@ pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesC )] } +pub(crate) fn color_balance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { + use graphene_std::raster::color_balance::*; + + let mut tone_info = ParameterWidgetsInfo::new(node_id, ToneInput, true, context); + tone_info.exposable = false; + let tone = enum_choice::().for_socket(tone_info).property_row(); + let preserve_luminosity = bool_widget(ParameterWidgetsInfo::new(node_id, PreserveLuminosityInput, true, context), CheckboxInput::default()); + + let document_node = match get_document_node(node_id, context) { + Ok(document_node) => document_node, + Err(err) => { + log::error!("Could not get document node in color_balance_properties: {err}"); + return Vec::new(); + } + }; + let tone_choice = match document_node.input_value(ToneInput) { + Some(TaggedValue::TonalRange(choice)) => *choice, + _ => { + warn!("Color Balance node properties panel could not be displayed."); + return vec![]; + } + }; + + // Only the selected tone's three sliders are shown + let parameters: [ParameterRef; 3] = match tone_choice { + TonalRange::Shadows => [ShadowsCyanRedInput.into(), ShadowsMagentaGreenInput.into(), ShadowsYellowBlueInput.into()], + TonalRange::Midtones => [MidtonesCyanRedInput.into(), MidtonesMagentaGreenInput.into(), MidtonesYellowBlueInput.into()], + TonalRange::Highlights => [HighlightsCyanRedInput.into(), HighlightsMagentaGreenInput.into(), HighlightsYellowBlueInput.into()], + }; + let tracks = [ + Gradient::from(vec![Color::CYAN, Color::RED]), + Gradient::from(vec![Color::MAGENTA, Color::GREEN]), + Gradient::from(vec![Color::YELLOW, Color::BLUE]), + ]; + let number_input = NumberInput::default().mode_increment().unit("%").min(-100.).max(100.); + + let mut layout = vec![tone]; + for (parameter, track) in parameters.into_iter().zip(tracks) { + layout.push(spectrum_slider_row(node_id, context, parameter, track, Color::WHITE, -100., 100., 0., number_input.clone())); + } + layout.push(LayoutGroup::row(preserve_luminosity)); + + layout +} + pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::black_and_white::*; diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index fb5b184fb1..198cc9affa 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -562,6 +562,7 @@ tagged_value! { DomainWarpType(raster_nodes::adjustments::DomainWarpType), RelativeAbsolute(raster_nodes::adjustments::RelativeAbsolute), SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), + TonalRange(raster_nodes::adjustments::TonalRange), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), GridType(vector::misc::GridType), ArcType(vector::misc::ArcType), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index a298900c30..cc20a0832c 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -356,6 +356,7 @@ fn node_registry() -> HashMap>( input } +#[repr(u32)] +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)] +#[widget(Dropdown)] +pub enum TonalRange { + Shadows, + #[default] + Midtones, + Highlights, +} + +/// A Levels-style tone curve: input black and white points on a 0..255 scale and a gamma exponent. For gamma above 1 the +/// power curve's slope is unbounded at black, so a cubic toe holds it to 2^gamma until past where that line meets the curve. +#[derive(Debug, Clone, Copy)] +struct LevelsCurve { + black: f32, + white: f32, + exponent: f32, + toe_end: f32, + toe_value: f32, + toe_slope_start: f32, + toe_slope_end: f32, +} + +impl LevelsCurve { + fn new(black: i32, white: i32, gamma: f32) -> Self { + Self::from_points(black as f32, white as f32, gamma) + } + + /// `gamma` is the Levels dialog value (pixel exponent 1/gamma). + fn from_points(black: f32, white: f32, gamma: f32) -> Self { + let gamma = gamma.max(0.01); + let black = black.min(white - 1.); + let exponent = 1. / gamma; + + let mut curve = Self { + black, + white, + exponent, + toe_end: 0., + toe_value: 0., + toe_slope_start: 0., + toe_slope_end: 0., + }; + if gamma > 1. { + let slope = 2_f32.powf(gamma); + let intersection = 255. * slope.powf(-1. / (1. - exponent)); + // The cubic toe joins the power curve at twice the intersection + if intersection > 1e-3 { + let toe_end = 2. * intersection; + curve.toe_end = toe_end; + curve.toe_value = 255. * (toe_end / 255.).powf(exponent); + curve.toe_slope_start = slope; + curve.toe_slope_end = exponent * (toe_end / 255.).powf(exponent - 1.); + } + } + curve + } + + /// Maps one gamma-space channel value in 0..1. + fn apply(&self, value: f32) -> f32 { + let t = ((value * 255. - self.black) * 255. / (self.white - self.black)).clamp(0., 255.); + let y = if t < self.toe_end { + let u = t / self.toe_end; + let hermite_start = u * u * u - 2. * u * u + u; + let hermite_end_value = 3. * u * u - 2. * u * u * u; + let hermite_end_slope = u * u * u - u * u; + hermite_start * self.toe_end * self.toe_slope_start + hermite_end_value * self.toe_value + hermite_end_slope * self.toe_end * self.toe_slope_end + } else { + 255. * (t / 255.).powf(self.exponent) + }; + y / 255. + } +} + +/// One channel's Levels parameters from its own slider values, and (with preserve_luminosity) the slider +/// extremes across all three channels. The halvings truncate toward zero, as PSD interop requires. +fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i32, h_min: i32, preserve_luminosity: bool) -> LevelsCurve { + let (black, white, tone) = if preserve_luminosity { + (s_max - s, 255 - (h - h_min), m - (m_max + m_min) / 2) + } else { + (0.max(-s), 255 - 0.max(h), (s + h) / 2 + m) + }; + + // Rounding the derived gamma to hundredths, the precision of a PSD Levels record, is needed for compatible results + let gamma = (2_f32.powf(tone as f32 / 100.) * 100.).round() / 100.; + LevelsCurve::new(black, white, gamma) +} + +// Aims for interoperable compatibility with: +// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blnc%27%20%3D%20Color%20Balance +// +// Every channel is a Levels curve whose black point, white point, and two-decimal gamma are derived from +// the nine sliders, see `color_balance_curve`. +#[node_macro::node(category("Raster: Adjustment"), properties("color_balance_properties"), shader_node(PerPixelAdjust))] +fn color_balance>( + _: impl Ctx, + #[implementations(Raster, Color, Gradient)] + #[gpu_image] + image: Item, + + #[name("(Shadows) Cyan-Red")] shadows_cyan_red: Item, + #[name("(Shadows) Magenta-Green")] shadows_magenta_green: Item, + #[name("(Shadows) Yellow-Blue")] shadows_yellow_blue: Item, + + #[name("(Midtones) Cyan-Red")] midtones_cyan_red: Item, + #[name("(Midtones) Magenta-Green")] midtones_magenta_green: Item, + #[name("(Midtones) Yellow-Blue")] midtones_yellow_blue: Item, + + #[name("(Highlights) Cyan-Red")] highlights_cyan_red: Item, + #[name("(Highlights) Magenta-Green")] highlights_magenta_green: Item, + #[name("(Highlights) Yellow-Blue")] highlights_yellow_blue: Item, + + #[default(true)] preserve_luminosity: Item, + + // Display-only property (not used within the node) + _tone: Item, +) -> Item { + let mut image = image; + let preserve_luminosity = preserve_luminosity.into_element(); + + // The derivation below is integer arithmetic, so the sliders round to whole percentages first + let slider = |value: Item| value.into_element().clamp(-100., 100.).round() as i32; + let (s_r, s_g, s_b) = (slider(shadows_cyan_red), slider(shadows_magenta_green), slider(shadows_yellow_blue)); + let (m_r, m_g, m_b) = (slider(midtones_cyan_red), slider(midtones_magenta_green), slider(midtones_yellow_blue)); + let (h_r, h_g, h_b) = (slider(highlights_cyan_red), slider(highlights_magenta_green), slider(highlights_yellow_blue)); + + let s_max = s_r.max(s_g).max(s_b); + let m_max = m_r.max(m_g).max(m_b); + let m_min = m_r.min(m_g).min(m_b); + let h_min = h_r.min(h_g).min(h_b); + let red = color_balance_curve(s_r, m_r, h_r, s_max, m_max, m_min, h_min, preserve_luminosity); + let green = color_balance_curve(s_g, m_g, h_g, s_max, m_max, m_min, h_min, preserve_luminosity); + let blue = color_balance_curve(s_b, m_b, h_b, s_max, m_max, m_min, h_min, preserve_luminosity); + + image.element_mut().adjust(|color| { + // The curves operate on gamma-space channel values + let [r, g, b, a] = color.to_gamma_srgb_channels(); + Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), a) + }); + image +} + #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, + SelectiveColorChoice, TonalRange, }; graphene_hash::impl_via_hash!( LuminanceCalculation, @@ -1252,7 +1393,8 @@ mod _graphene_hash_impls { DomainWarpType, RelativeAbsolute, SelectiveColorChoice, - AdjustmentChannel + AdjustmentChannel, + TonalRange, ); } @@ -1271,3 +1413,73 @@ mod test { assert!((a - 0.5).abs() < 1e-5, "alpha was {a}"); } } + +#[cfg(all(feature = "std", test))] +mod color_balance_tests { + use super::*; + + /// Runs Color Balance on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. + fn run(input: [f32; 3], shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool) -> [f32; 3] { + let color = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); + let result = color_balance( + (), + Item::new_from_element(color), + shadows[0].into(), + shadows[1].into(), + shadows[2].into(), + midtones[0].into(), + midtones[1].into(), + midtones[2].into(), + highlights[0].into(), + highlights[1].into(), + highlights[2].into(), + preserve_luminosity.into(), + TonalRange::Midtones.into(), + ); + let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255., b * 255.] + } + + /// Matched to within one 8-bit level. + fn assert_close(actual: [f32; 3], expected: [f32; 3]) { + for (actual, expected) in actual.iter().zip(expected) { + assert!((actual - expected).abs() <= 1., "expected {expected}, got {actual}"); + } + } + + #[test] + fn midtones_are_a_gamma_with_a_toe() { + let none = [0., 0., 0.]; + assert_close(run([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]); + assert_close(run([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]); + assert_close(run([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]); + assert_close(run([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]); + assert_close(run([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]); + } + + #[test] + fn shadows_and_highlights_move_the_end_points() { + let none = [0., 0., 0.]; + assert_close(run([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]); + assert_close(run([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]); + assert_close(run([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]); + assert_close(run([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]); + assert_close(run([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]); + } + + #[test] + fn preserve_luminosity_makes_sliders_relative() { + let none = [0., 0., 0.]; + assert_close(run([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]); + assert_close(run([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]); + assert_close(run([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]); + assert_close(run([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]); + } + + #[test] + fn combined_tones_use_integer_arithmetic() { + // Red: black 39, white 235, gamma 1.09; green: gamma 0.91; blue: white 210, gamma 1.13 + let result = run([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false); + assert_close(result, [124., 120., 164.]); + } +}