#![allow(clippy::too_many_arguments)] use crate::adjust::Adjust; use crate::cubic_spline::CubicSplines; use core::fmt::Debug; #[cfg(feature = "std")] use core_types::list::{Item, List}; #[cfg(feature = "std")] use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator}; #[cfg(feature = "std")] use glam::DVec2; use glam::Vec3; use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear}; use no_std_types::context::Ctx; #[cfg(not(feature = "std"))] use no_std_types::list::ShaderItem as Item; use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32}; use node_macro::BufferStruct; use num_enum::{FromPrimitive, IntoPrimitive}; #[cfg(not(feature = "std"))] use num_traits::float::Float; #[cfg(feature = "std")] use raster_types::{CPU, Raster}; #[cfg(feature = "std")] use vector_types::Gradient; // TODO: Implement the following: // Photo Filter // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl // // Color Lookup // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6 /// Conversion from a color to grayscale. #[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, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)] #[widget(Dropdown)] #[repr(u32)] pub enum DesaturateMethod { /// Light level of the color, the Y (luminance) of Rec. 709, which weights the linear-light RGB channels by `0.2126, 0.7152, 0.0722`. /// /// Accessibility contrast ratios and SVG luminance masks use this. #[default] #[label("Luminance (Rec. 709)")] #[cfg_attr(feature = "serde", serde(alias = "SRGB"))] LuminanceRec709, /// Light level approximation for the color, the Y′ (luma) of Rec. 709, which weights the gamma-encoded RGB channels by `0.2126, 0.7152, 0.0722`. /// /// CSS filter functions such as `grayscale()` use this. #[label("Luma (Rec. 709)")] LumaRec709, /// Light level approximation for the color, the Y′ (luma) of Rec. 601, which weights the gamma-encoded RGB channels by `0.299, 0.587, 0.114`. #[label("Luma (Rec. 601)")] LumaRec601, /// Perceptually uniform scale from black to white, the L (lightness) of OkLab. #[label("Lightness (OkLab)")] #[cfg_attr(feature = "serde", serde(alias = "Perceptual"))] LightnessOkLab, /// Mean of the three linear-light RGB channels. #[menu_separator] #[cfg_attr(feature = "serde", serde(alias = "AverageChannels"))] ChannelsAverage, /// Smallest of the three linear-light RGB channels. #[cfg_attr(feature = "serde", serde(alias = "MinimumChannels"))] ChannelsMinimum, /// Largest of the three linear-light RGB channels, the V (value) of HSV. #[cfg_attr(feature = "serde", serde(alias = "MaximumChannels"))] ChannelsMaximum, /// Midpoint of the largest and smallest gamma-encoded RGB channels, the L (lightness) of HSL. /// /// The classic "Desaturate" command of many image editors uses this. #[label("Lightness (HSL)")] LightnessHsl, } #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn desaturate>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, method: Item, ) -> Item { let mut input = input; let method = method.into_element(); input.element_mut().adjust(|color| { // Gamma-encoded formulas are decoded as if they were a gray let gamma = || color.to_gamma_srgb_channels(); let luminance = match method { DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(), DesaturateMethod::LumaRec709 => { let [r, g, b, _] = gamma(); srgb_to_linear(0.2126 * r + 0.7152 * g + 0.0722 * b) } DesaturateMethod::LumaRec601 => { let [r, g, b, _] = gamma(); srgb_to_linear(0.299 * r + 0.587 * g + 0.114 * b) } DesaturateMethod::LightnessOkLab => { // A gray's OkLab lightness is the cube root of its linear value, so cubing gives the gray of equal lightness let lightness = color.lightness_oklab(); lightness * lightness * lightness } DesaturateMethod::ChannelsAverage => color.average_rgb_channels(), DesaturateMethod::ChannelsMinimum => color.minimum_rgb_channels(), DesaturateMethod::ChannelsMaximum => color.maximum_rgb_channels(), DesaturateMethod::LightnessHsl => { // The transfer curve is monotonic, so the extremes are found first and only they are encoded let max = linear_to_srgb(color.maximum_rgb_channels()); let min = linear_to_srgb(color.minimum_rgb_channels()); srgb_to_linear((max + min) / 2.) } }; color.map_rgb(|_| luminance) }); input } #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn gamma_correction>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, #[default(2.2)] #[range] #[hard(0.0001..)] #[soft(0.01..10)] gamma: Item, inverse: Item, ) -> Item { let mut input = input; let gamma = gamma.into_element(); let inverse = inverse.into_element(); let exponent = if inverse { 1. / gamma } else { gamma }; input.element_mut().adjust(|color| color.apply_gamma_exponent(exponent)); input } #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] fn extract_channel>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, channel: Item, ) -> Item { let mut input = input; let channel = channel.into_element(); input.element_mut().adjust(|color| { let extracted_value = match channel { RedGreenBlueAlpha::Red => color.r(), RedGreenBlueAlpha::Green => color.g(), RedGreenBlueAlpha::Blue => color.b(), RedGreenBlueAlpha::Alpha => color.a(), }; color.map_rgb(|_| extracted_value).with_alpha(1.) }); input } #[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))] fn make_opaque>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, ) -> Item { let mut input = input; input.element_mut().adjust(|color| color.with_alpha(1.)); input } // TODO: Remove this once GPU shader nodes are able to support the non-classic algorithm // TODO: Maybe re-add the "Raster: Adjustment" category to make this user-facing if we care to make this not just for testing #[node_macro::node(name("Brightness/Contrast Classic"), category(""), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))] fn brightness_contrast_classic>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, brightness: Item, contrast: Item, ) -> Item { let mut input = input; let brightness = brightness.into_element(); let contrast = contrast.into_element(); let brightness = brightness / 255.; let contrast = contrast / 100.; let contrast = if contrast > 0. { (contrast * core::f32::consts::FRAC_PI_2 - 0.01).tan() } else { contrast }; let offset = brightness * contrast + brightness - contrast / 2.; input.element_mut().adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.))); input } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27brit%27%20%3D%20Brightness/Contrast // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Padding-,Brightness%20and%20Contrast,-Key%20is%20%27brit // // Some further analysis available at: // https://geraldbakker.nl/psnumbers/brightness-contrast.html #[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), properties("brightness_contrast_properties"), cfg(feature = "std"))] fn brightness_contrast>( _ctx: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, brightness: Item, contrast: Item, use_classic: Item, ) -> Item { let use_classic = use_classic.into_element(); if use_classic { return brightness_contrast_classic(_ctx, input, brightness, contrast); } let mut input = input; let brightness = brightness.into_element(); let contrast = contrast.into_element(); const WINDOW_SIZE: usize = 1024; // Brightness LUT let brightness_is_negative = brightness < 0.; // We clamp the brightness before the two curve X-axis points `130 - brightness * 26` and `233 - brightness * 48` intersect. // Beyond the point of intersection, the cubic spline fitting becomes invalid and fails an assertion, which we need to avoid. // See the intersection of the red lines at x = 103/22*100 = 468.18182 in the graph: https://www.desmos.com/calculator/ekvz4zyd9c let brightness = (brightness.abs() / 100.).min(103. / 22. - 0.00001); let brightness_curve_points = CubicSplines { x: [0., 130. - brightness * 26., 233. - brightness * 48., 255.].map(|x| x / 255.), y: [0., 130. + brightness * 51., 233. + brightness * 10., 255.].map(|x| x / 255.), }; let brightness_curve_solutions = brightness_curve_points.solve(); let mut brightness_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| { let x = i as f32 / (WINDOW_SIZE as f32 - 1.); brightness_curve_points.interpolate(x, &brightness_curve_solutions) }); // Special handling for when brightness is negative if brightness_is_negative { brightness_lut = core::array::from_fn(|i| { let mut x = i; while x > 1 && brightness_lut[x] > i as f32 / WINDOW_SIZE as f32 { x -= 1; } x as f32 / WINDOW_SIZE as f32 }); } // Contrast LUT // Unlike with brightness, the X-axis points `64` and `192` don't intersect at any contrast value, because they are constants. // So we don't have to worry about clamping the contrast value to avoid invalid cubic spline fitting. // See the graph: https://www.desmos.com/calculator/iql9vsca56 let contrast = contrast / 100.; let contrast_curve_points = CubicSplines { x: [0., 64., 192., 255.].map(|x| x / 255.), y: [0., 64. - contrast * 30., 192. + contrast * 30., 255.].map(|x| x / 255.), }; let contrast_curve_solutions = contrast_curve_points.solve(); let contrast_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| { let x = i as f32 / (WINDOW_SIZE as f32 - 1.); contrast_curve_points.interpolate(x, &contrast_curve_solutions) }); // Composed brightness and contrast LUTs let combined_lut = brightness_lut.map(|brightness| { let index_in_contrast_lut = (brightness * (contrast_lut.len() - 1) as f32).round() as usize; contrast_lut[index_in_contrast_lut] }); let lut_max = (combined_lut.len() - 1) as f32; input.element_mut().adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize])); 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)] /// The channel whose settings are shown, with RGB adjusting all three color channels together. pub enum AdjustmentChannel { #[default] #[label("RGB")] Rgb, Red, Green, Blue, Alpha, } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels // // Algorithm from: // https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels // // Some further analysis available at: // https://geraldbakker.nl/psnumbers/levels.html #[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))] fn levels>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] image: Item, #[default(0.)] shadows: Item, #[default(50.)] midtones: Item, #[default(100.)] highlights: Item, #[default(0.)] output_minimums: Item, #[default(100.)] output_maximums: Item, ) -> Item { let mut image = image; let shadows = shadows.into_element(); let midtones = midtones.into_element(); let highlights = highlights.into_element(); let output_minimums = output_minimums.into_element(); let output_maximums = output_maximums.into_element(); image.element_mut().adjust(|color| { // Levels math operates in gamma space let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels(); // Input Range (Range: 0-1) let input_shadows = shadows / 100.; let input_midtones = midtones / 100.; let input_highlights = highlights / 100.; // Output Range (Range: 0-1) let output_minimums = output_minimums / 100.; let output_maximums = output_maximums / 100.; // Midtones interpolation factor between minimums and maximums (Range: 0-1) let midtones = output_minimums + (output_maximums - output_minimums) * input_midtones; // Gamma correction (Range: 0.01-10) let gamma = if midtones < 0.5 { // Range: 0-1 let x = 1. - midtones * 2.; // Range: 1-10 1. + 9. * x } else { // Range: 0-0.5 let x = 1. - midtones; // Range: 0-1 let x = x * 2.; // Range: 0.01-1 x.max(0.01) }; // Input levels (Range: 0-1) let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.); let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.); r = input_map(r); g = input_map(g); b = input_map(b); // Midtones gamma curve (Range: 0-1) let inverse_gamma = 1. / gamma.max(0.0001); r = r.powf(inverse_gamma); g = g.powf(inverse_gamma); b = b.powf(inverse_gamma); // Output levels (Range: 0-1) let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums; r = output_map(r); g = output_map(g); b = output_map(b); Color::from_gamma_srgb_channels(r, g, b, a) }); image } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27curv%27%20%3D%20Curves // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Curves%20file%20format // // Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost // points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages // stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines. // Needs the heap for its curves, so it stays off the shader build for now. #[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), properties("transfer_curves_properties"))] async fn curves + Send>( _: impl Ctx, #[implementations(Raster, Color, Gradient)] image: Item, curve: Item, #[name("(Red) Curve")] red_curve: Item, #[name("(Green) Curve")] green_curve: Item, #[name("(Blue) Curve")] blue_curve: Item, #[name("(Alpha) Curve")] alpha_curve: Item, _channel: Item, ) -> Item { let mut image = image; let composite = curve.into_element().evaluator(); let red = red_curve.into_element().evaluator(); let green = green_curve.into_element().evaluator(); let blue = blue_curve.into_element().evaluator(); let alpha = alpha_curve.into_element().evaluator(); let map = |channel: &TransferCurveEvaluator, value: f32| composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32; image.element_mut().adjust(|color| { // Curves math operates in gamma space let [r, g, b, a] = color.to_gamma_srgb_channels(); // Alpha stands apart from the composite curve that the three color channels pass through let a = alpha.evaluate(a as f64).clamp(0., 1.) as f32; Color::from_gamma_srgb_channels(map(&red, r), map(&green, g), map(&blue, b), a) }); image } /// Builds a transfer curve from a `Vec2[]` of control points, each mapping the input value at its x to the output value at its y. A smooth spline runs through them, holding the outermost points' values beyond them. #[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"), name("Points to Transfer Curve"))] fn points_to_transfer_curve( _: impl Ctx, /// The control points, in any order, with both coordinates on the 0 to 1 range. points: List, ) -> Item { let points: Vec = points.iter_element_values().copied().collect(); Item::new_from_element(TransferCurve::new(points)) } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blwh%27%20%3D%20Black%20and%20White // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Black%20White%20(Photoshop%20CS3) // // Algorithm from: // https://stackoverflow.com/a/55233732/775283 // Works the same for gamma and linear color #[node_macro::node(name("Black & White"), category("Raster: Adjustment"), properties("black_and_white_properties"), shader_node(PerPixelAdjust))] fn black_and_white>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] image: Item, #[default(Color::BLACK)] tint: Item, #[default(40.)] #[range] #[soft(-200..300)] reds: Item, #[default(60.)] #[range] #[soft(-200..300)] yellows: Item, #[default(40.)] #[range] #[soft(-200..300)] greens: Item, #[default(60.)] #[range] #[soft(-200..300)] cyans: Item, #[default(20.)] #[range] #[soft(-200..300)] blues: Item, #[default(80.)] #[range] #[soft(-200..300)] magentas: Item, ) -> Item { let mut image = image; let tint = tint.into_element(); let reds = reds.into_element(); let yellows = yellows.into_element(); let greens = greens.into_element(); let cyans = cyans.into_element(); let blues = blues.into_element(); let magentas = magentas.into_element(); image.element_mut().adjust(|color| { // Black & White channel weights are tuned for gamma-space values let [r, g, b, alpha_part] = color.to_gamma_srgb_channels(); let reds = reds / 100.; let yellows = yellows / 100.; let greens = greens / 100.; let cyans = cyans / 100.; let blues = blues / 100.; let magentas = magentas / 100.; let gray_base = r.min(g).min(b); let red_part = r - gray_base; let green_part = g - gray_base; let blue_part = b - gray_base; let additional = if red_part == 0. { let cyan_part = green_part.min(blue_part); cyan_part * cyans + (green_part - cyan_part) * greens + (blue_part - cyan_part) * blues } else if green_part == 0. { let magenta_part = red_part.min(blue_part); magenta_part * magentas + (red_part - magenta_part) * reds + (blue_part - magenta_part) * blues } else { let yellow_part = red_part.min(green_part); yellow_part * yellows + (red_part - yellow_part) * reds + (green_part - yellow_part) * greens }; let luminance = gray_base + additional; // TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close) // Apply luminance substitution in gamma space let [tr, tg, tb, _] = tint.to_gamma_srgb_channels(); let tint_luma_rec_601 = 0.3 * tr + 0.59 * tg + 0.11 * tb; let delta = luminance - tint_luma_rec_601; let result_r = (tr + delta).clamp(0., 1.); let result_g = (tg + delta).clamp(0., 1.); let result_b = (tb + delta).clamp(0., 1.); Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part) }); image } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0 // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings #[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))] fn hue_saturation>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, hue_shift: Item, saturation_shift: Item, lightness_shift: Item, ) -> Item { let mut input = input; let hue_shift = hue_shift.into_element(); let saturation_shift = saturation_shift.into_element(); let lightness_shift = lightness_shift.into_element(); input.element_mut().adjust(|color| { // HSL operates on gamma-space channels let [hue, saturation, lightness, alpha] = color.to_hsla(); Color::from_hsla( (hue + hue_shift / 360.) % 1., // TODO: Improve the way saturation works (it's slightly off) (saturation + saturation_shift / 100.).clamp(0., 1.), // TODO: Fix the way lightness works (it's very off) (lightness + lightness_shift / 100.).clamp(0., 1.), alpha, ) }); input } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27%20%3D%20Color%20Lookup-,%27nvrt%27%20%3D%20Invert,-%27post%27%20%3D%20Posterize #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn invert>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, ) -> Item { let mut input = input; input.element_mut().adjust(|color| color.map_gamma_rgb(|channel| 1. - channel)); input } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient #[node_macro::node(category("Raster: Adjustment"), properties("threshold_properties"), shader_node(PerPixelAdjust))] fn threshold>( _: impl Ctx, #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, #[default(50.)] min_luminance: Item, #[default(100.)] max_luminance: Item, ) -> Item { let mut image = image; let min_luminance = min_luminance.into_element() / 100.; let max_luminance = max_luminance.into_element() / 100.; image.element_mut().adjust(|color| { // For PSD interop, we compare this 14-bit fixed-point Rec. 601 luma against the level unrounded let [r, g, b, _] = color.to_gamma_srgb_channels(); let luminance = (4915. * r + 9667. * g + 1802. * b) / 16384.; let output = if luminance >= min_luminance && luminance <= max_luminance { Color::WHITE } else { Color::BLACK }; output.with_alpha(color.a()) }); image } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0) // // TODO: Full PSD interop needs a compatibility variant of `GradientInterpolation` with its own midpoint semantics, position warp, // TODO: and smoothing (a `gradient_smoothness` attribute), plus noise gradients, which we don't yet support. // TODO: Its axes differ from ours: its midpoint is always a knee in the position warp and its smoothness blends the curve over // TODO: that fixed warp, while each variant here picks warp and curve together, so neither end of the blend is Linear or Smooth. // TODO: Per channel in the gradient space (measured on gamma RGB): // TODO: - Position t maps to a parameter p by a piecewise-linear knee through (stop position, index) and (midpoint, index - 0.5). // TODO: - Linear lerps the interval's stop colors by the fraction of p. Smooth is a cubic Hermite over the stop index with tangent // TODO: `(c[i + 1] - c[i - 1]) / 2`, the end stops repeated past the ends, so two stops give `0.5 p + 1.5 p^2 - p^3`. // TODO: - The ramp is `(1 - s) * linear + s * smooth` for smoothness s, clamped per interval to its two stop colors. #[cfg(feature = "std")] #[node_macro::node(category("Raster: Adjustment"))] async fn gradient_map + Send>( _: impl Ctx, #[implementations(Raster, Color, Gradient)] image: Item, #[default(Color::BLACK, Color::WHITE)] gradient: Item, reverse: Item, ) -> Item { let mut image = image; let settings = vector_types::GradientSettings::from(&gradient); let evaluator = gradient.into_element().evaluator(settings); let reverse = reverse.into_element(); image.element_mut().adjust(|color| { // The classic 0.3/0.59/0.11 luma of the gamma-encoded channels picks the position along the gradient let [r, g, b, alpha] = color.to_gamma_srgb_channels(); let intensity = 0.3 * r + 0.59 * g + 0.11 * b; let intensity = if reverse { 1. - intensity } else { intensity }; // The source alpha is kept and the gradient's own alpha stops are ignored evaluator.evaluate(intensity as f64).with_alpha(alpha) }); image } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3) // // Algorithm based on: // https://stackoverflow.com/questions/33966121/what-is-the-algorithm-for-vibrance-filters // The results of this implementation are very close to correct, but not quite perfect. // // Some further analysis available at: // https://www.photo-mark.com/notes/analyzing-photoshop-vibrance-and-saturation/ // // This algorithm is currently lacking a "Saturation" parameter which is needed for interoperability. // It's not the same as the saturation component of Hue/Saturation/Value. Vibrance and Saturation are both separable. // When both parameters are set, it is equivalent to running this adjustment twice, with only vibrance set and then only saturation set. // (Except for some noise probably due to rounding error.) #[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))] fn vibrance>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] image: Item, vibrance: Item, ) -> Item { let mut image = image; let vibrance = vibrance.into_element(); image.element_mut().adjust(|color| { let r_raw = color.r(); let g_raw = color.g(); let b_raw = color.b(); let alpha_in = color.a(); let vibrance = vibrance / 100.; // Slow the effect down by half when it's negative, since artifacts begin appearing past -50%. // So this scales the 0% to -50% range to 0% to -100%. let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 }; let channel_max = r_raw.max(g_raw).max(b_raw); let channel_min = r_raw.min(g_raw).min(b_raw); let channel_difference = channel_max - channel_min; let scale_multiplier = if channel_max == r_raw { let green_blue_difference = (g_raw - b_raw).abs(); let t = (green_blue_difference / channel_difference).min(1.); t * 0.5 + 0.5 } else { 1. }; let scale = slowed_vibrance * scale_multiplier * (2. - channel_difference); let channel_reduction = channel_min * scale; let scale = 1. + scale * (1. - channel_difference); let r_lin0 = srgb_to_linear(r_raw); let g_lin0 = srgb_to_linear(g_raw); let b_lin0 = srgb_to_linear(b_raw); let luminance_initial = 0.2126 * r_lin0 + 0.7152 * g_lin0 + 0.0722 * b_lin0; let mut alt_r = srgb_to_linear(r_raw * scale - channel_reduction); let mut alt_g = srgb_to_linear(g_raw * scale - channel_reduction); let mut alt_b = srgb_to_linear(b_raw * scale - channel_reduction); let luminance = 0.2126 * alt_r + 0.7152 * alt_g + 0.0722 * alt_b; // Skip the luminance-preservation scaling when the result is black (e.g. black input pixel), avoiding division by zero. if luminance > 0. { alt_r *= luminance_initial / luminance; alt_g *= luminance_initial / luminance; alt_b *= luminance_initial / luminance; } let channel_max = alt_r.max(alt_g).max(alt_b); if linear_to_srgb(channel_max) > 1. { let scale = (1. - luminance) / (channel_max - luminance); alt_r = (alt_r - luminance) * scale + luminance; alt_g = (alt_g - luminance) * scale + luminance; alt_b = (alt_b - luminance) * scale + luminance; } alt_r = linear_to_srgb(alt_r); alt_g = linear_to_srgb(alt_g); alt_b = linear_to_srgb(alt_b); if vibrance >= 0. { Color::from_rgbaf32_unchecked(alt_r, alt_g, alt_b, alpha_in) } else { // TODO: The result ends up a bit darker than it should be, further investigation is needed. // Mix in gamma space (matching `alt_*`), so the luminance is computed from gamma channels too. let [gr, gg, gb, _] = color.to_gamma_srgb_channels(); let luminance = 0.299 * gr + 0.587 * gg + 0.114 * gb; let factor = -slowed_vibrance; Color::from_rgbaf32_unchecked( alt_r * (1. - factor) + luminance * factor, alt_g * (1. - factor) + luminance * factor, alt_b * (1. - factor) + luminance * factor, alpha_in, ) } }); image } #[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(Radio)] pub enum RedGreenBlue { #[default] Red, Green, Blue, } #[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, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)] #[widget(Radio)] #[repr(u32)] pub enum RedGreenBlueAlpha { #[default] Red, Green, Blue, Alpha, } /// Style of noise pattern. #[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)] #[widget(Dropdown)] pub enum NoiseType { #[default] Perlin, #[label("OpenSimplex2")] OpenSimplex2, #[label("OpenSimplex2S")] OpenSimplex2S, Cellular, ValueCubic, Value, WhiteNoise, } /// Style of layered levels of the noise pattern. #[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)] pub enum FractalType { #[default] None, #[label("Fractional Brownian Motion")] FBm, Ridged, PingPong, #[label("Progressive (Domain Warp Only)")] DomainWarpProgressive, #[label("Independent (Domain Warp Only)")] DomainWarpIndependent, } /// Distance function used by the cellular noise. #[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)] pub enum CellularDistanceFunction { #[default] Euclidean, #[label("Euclidean Squared (Faster)")] EuclideanSq, Manhattan, Hybrid, } #[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)] pub enum CellularReturnType { CellValue, #[default] #[label("Nearest (F1)")] Nearest, #[label("Next Nearest (F2)")] NextNearest, #[label("Average (F1 / 2 + F2 / 2)")] Average, #[label("Difference (F2 - F1)")] Difference, #[label("Product (F2 * F1 / 2)")] Product, #[label("Division (F1 / F2)")] Division, } #[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)] #[widget(Dropdown)] pub enum DomainWarpType { #[default] None, #[label("OpenSimplex2")] OpenSimplex2, #[label("OpenSimplex2 Reduced")] OpenSimplex2Reduced, BasicGrid, } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27mixr%27%20%3D%20Channel%20Mixer // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Lab%20color%20only-,Channel%20Mixer,-Key%20is%20%27mixr #[node_macro::node(category("Raster: Adjustment"), properties("channel_mixer_properties"), shader_node(PerPixelAdjust))] fn channel_mixer>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] image: Item, monochrome: Item, #[default(40.)] #[name("Red")] monochrome_r: Item, #[default(40.)] #[name("Green")] monochrome_g: Item, #[default(20.)] #[name("Blue")] monochrome_b: Item, #[default(0.)] #[name("Constant")] monochrome_c: Item, #[default(100.)] #[name("(Red) Red")] red_r: Item, #[default(0.)] #[name("(Red) Green")] red_g: Item, #[default(0.)] #[name("(Red) Blue")] red_b: Item, #[default(0.)] #[name("(Red) Constant")] red_c: Item, #[default(0.)] #[name("(Green) Red")] green_r: Item, #[default(100.)] #[name("(Green) Green")] green_g: Item, #[default(0.)] #[name("(Green) Blue")] green_b: Item, #[default(0.)] #[name("(Green) Constant")] green_c: Item, #[default(0.)] #[name("(Blue) Red")] blue_r: Item, #[default(0.)] #[name("(Blue) Green")] blue_g: Item, #[default(100.)] #[name("(Blue) Blue")] blue_b: Item, #[default(0.)] #[name("(Blue) Constant")] blue_c: Item, // Display-only properties (not used within the node) _output_channel: Item, ) -> Item { let mut image = image; let monochrome = monochrome.into_element(); let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r.into_element(), monochrome_g.into_element(), monochrome_b.into_element(), monochrome_c.into_element()); let (red_r, red_g, red_b, red_c) = (red_r.into_element(), red_g.into_element(), red_b.into_element(), red_c.into_element()); let (green_r, green_g, green_b, green_c) = (green_r.into_element(), green_g.into_element(), green_b.into_element(), green_c.into_element()); let (blue_r, blue_g, blue_b, blue_c) = (blue_r.into_element(), blue_g.into_element(), blue_b.into_element(), blue_c.into_element()); image.element_mut().adjust(|color| { let [r, g, b, a] = color.to_gamma_srgb_channels(); // Weights and constants are 10-bit fixed point truncated toward zero, which PSD interop depends on let weight = |percent: f32| (percent * 1024. / 100.).trunc() / 1024.; let (out_r, out_g, out_b) = if monochrome { let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (weight(monochrome_r), weight(monochrome_g), weight(monochrome_b), weight(monochrome_c)); let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.); (gray, gray, gray) } else { let (red_r, red_g, red_b, red_c) = (weight(red_r), weight(red_g), weight(red_b), weight(red_c)); let (green_r, green_g, green_b, green_c) = (weight(green_r), weight(green_g), weight(green_b), weight(green_c)); let (blue_r, blue_g, blue_b, blue_c) = (weight(blue_r), weight(blue_g), weight(blue_b), weight(blue_c)); let red = (r * red_r + g * red_g + b * red_b + red_c).clamp(0., 1.); let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.); let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.); (red, green, blue) }; Color::from_gamma_srgb_channels(out_r, out_g, out_b, a) }); image } #[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(Radio)] pub enum RelativeAbsolute { #[default] Relative, Absolute, } #[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)] pub enum SelectiveColorChoice { #[default] Reds, Yellows, Greens, Cyans, Blues, Magentas, #[menu_separator] Whites, Neutrals, Blacks, } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27selc%27%20%3D%20Selective%20color // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=from%20%2D100...100.%20.-,Selective%20Color,-Selective%20Color%20settings // // Algorithm based on: // https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html #[node_macro::node(category("Raster: Adjustment"), properties("selective_color_properties"), shader_node(PerPixelAdjust))] fn selective_color>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] image: Item, mode: Item, #[name("(Reds) Cyan")] r_c: Item, #[name("(Reds) Magenta")] r_m: Item, #[name("(Reds) Yellow")] r_y: Item, #[name("(Reds) Black")] r_k: Item, #[name("(Yellows) Cyan")] y_c: Item, #[name("(Yellows) Magenta")] y_m: Item, #[name("(Yellows) Yellow")] y_y: Item, #[name("(Yellows) Black")] y_k: Item, #[name("(Greens) Cyan")] g_c: Item, #[name("(Greens) Magenta")] g_m: Item, #[name("(Greens) Yellow")] g_y: Item, #[name("(Greens) Black")] g_k: Item, #[name("(Cyans) Cyan")] c_c: Item, #[name("(Cyans) Magenta")] c_m: Item, #[name("(Cyans) Yellow")] c_y: Item, #[name("(Cyans) Black")] c_k: Item, #[name("(Blues) Cyan")] b_c: Item, #[name("(Blues) Magenta")] b_m: Item, #[name("(Blues) Yellow")] b_y: Item, #[name("(Blues) Black")] b_k: Item, #[name("(Magentas) Cyan")] m_c: Item, #[name("(Magentas) Magenta")] m_m: Item, #[name("(Magentas) Yellow")] m_y: Item, #[name("(Magentas) Black")] m_k: Item, #[name("(Whites) Cyan")] w_c: Item, #[name("(Whites) Magenta")] w_m: Item, #[name("(Whites) Yellow")] w_y: Item, #[name("(Whites) Black")] w_k: Item, #[name("(Neutrals) Cyan")] n_c: Item, #[name("(Neutrals) Magenta")] n_m: Item, #[name("(Neutrals) Yellow")] n_y: Item, #[name("(Neutrals) Black")] n_k: Item, #[name("(Blacks) Cyan")] k_c: Item, #[name("(Blacks) Magenta")] k_m: Item, #[name("(Blacks) Yellow")] k_y: Item, #[name("(Blacks) Black")] k_k: Item, _colors: Item, ) -> Item { let mut image = image; let mode = mode.into_element(); let (r_c, r_m, r_y, r_k) = (r_c.into_element(), r_m.into_element(), r_y.into_element(), r_k.into_element()); let (y_c, y_m, y_y, y_k) = (y_c.into_element(), y_m.into_element(), y_y.into_element(), y_k.into_element()); let (g_c, g_m, g_y, g_k) = (g_c.into_element(), g_m.into_element(), g_y.into_element(), g_k.into_element()); let (c_c, c_m, c_y, c_k) = (c_c.into_element(), c_m.into_element(), c_y.into_element(), c_k.into_element()); let (b_c, b_m, b_y, b_k) = (b_c.into_element(), b_m.into_element(), b_y.into_element(), b_k.into_element()); let (m_c, m_m, m_y, m_k) = (m_c.into_element(), m_m.into_element(), m_y.into_element(), m_k.into_element()); let (w_c, w_m, w_y, w_k) = (w_c.into_element(), w_m.into_element(), w_y.into_element(), w_k.into_element()); let (n_c, n_m, n_y, n_k) = (n_c.into_element(), n_m.into_element(), n_y.into_element(), n_k.into_element()); let (k_c, k_m, k_y, k_k) = (k_c.into_element(), k_m.into_element(), k_y.into_element(), k_k.into_element()); image.element_mut().adjust(|color| { let [r, g, b, a] = color.to_gamma_srgb_channels(); let min = |a: f32, b: f32, c: f32| a.min(b).min(c); let max = |a: f32, b: f32, c: f32| a.max(b).max(c); let med = |a: f32, b: f32, c: f32| a + b + c - min(a, b, c) - max(a, b, c); let max_channel = max(r, g, b); let min_channel = min(r, g, b); let pixel_color_range = |choice| match choice { SelectiveColorChoice::Reds => max_channel == r, SelectiveColorChoice::Yellows => min_channel == b, SelectiveColorChoice::Greens => max_channel == g, SelectiveColorChoice::Cyans => min_channel == r, SelectiveColorChoice::Blues => max_channel == b, SelectiveColorChoice::Magentas => min_channel == g, SelectiveColorChoice::Whites => r > 0.5 && g > 0.5 && b > 0.5, // Every pixel, since the neutrals scale factor already vanishes at black, white, and fully saturated colors SelectiveColorChoice::Neutrals => true, SelectiveColorChoice::Blacks => r < 0.5 && g < 0.5 && b < 0.5, }; let color_parameter_group_scale_factor_rgb = max(r, g, b) - med(r, g, b); let color_parameter_group_scale_factor_cmy = med(r, g, b) - min(r, g, b); // Used to apply the r, g, or b channel slope (by multiplying it by 1) in relative mode, or no slope (by multiplying it by 0) in absolute mode let (slope_r, slope_g, slope_b) = match mode { RelativeAbsolute::Relative => (r - 1., g - 1., b - 1.), RelativeAbsolute::Absolute => (-1., -1., -1.), }; let array = [ (SelectiveColorChoice::Reds, (r_c, r_m, r_y, r_k)), (SelectiveColorChoice::Yellows, (y_c, y_m, y_y, y_k)), (SelectiveColorChoice::Greens, (g_c, g_m, g_y, g_k)), (SelectiveColorChoice::Cyans, (c_c, c_m, c_y, c_k)), (SelectiveColorChoice::Blues, (b_c, b_m, b_y, b_k)), (SelectiveColorChoice::Magentas, (m_c, m_m, m_y, m_k)), (SelectiveColorChoice::Whites, (w_c, w_m, w_y, w_k)), (SelectiveColorChoice::Neutrals, (n_c, n_m, n_y, n_k)), (SelectiveColorChoice::Blacks, (k_c, k_m, k_y, k_k)), ]; let mut sum = Vec3::ZERO; // Indexed because the shader compiler cannot lower array iterators #[allow(clippy::needless_range_loop)] for i in 0..array.len() { let (color_parameter_group, (c, m, y, k)) = array[i]; // Skip this color parameter group... // ...if it's unchanged from the default of zero offset on all CMYK parameters, or... // ...if this pixel's color isn't in the range affected by this color parameter group if (c == 0. && m == 0. && y == 0. && k == 0.) || !pixel_color_range(color_parameter_group) { continue; } let color_parameter_group_scale_factor = match color_parameter_group { SelectiveColorChoice::Reds | SelectiveColorChoice::Greens | SelectiveColorChoice::Blues => color_parameter_group_scale_factor_rgb, SelectiveColorChoice::Cyans | SelectiveColorChoice::Magentas | SelectiveColorChoice::Yellows => color_parameter_group_scale_factor_cmy, SelectiveColorChoice::Whites => min(r, g, b) * 2. - 1., SelectiveColorChoice::Neutrals => 1. - ((max(r, g, b) - 0.5).abs() + (min(r, g, b) - 0.5).abs()), SelectiveColorChoice::Blacks => 1. - max(r, g, b) * 2., }; // For PSD interop, the combined percent (c + k + c k / 100) rounds half up to an integer let ink = |color: f32| { let percent = ((2. * (100. * (color + k) + color * k) + 100.) / 200.).floor(); match mode { // The multiplier is stored as one byte, 255 / b above 1 and b / 255 below, so 99% and 100% both act as 127/128 RelativeAbsolute::Relative => { let multiplier = 1. + percent / 100.; if multiplier >= 1. { 255. / (255. / multiplier).round() - 1. } else { (255. * multiplier).round() / 255. - 1. } } RelativeAbsolute::Absolute => percent / 100., } }; let offset_r = f32::clamp(ink(c) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor; let offset_g = f32::clamp(ink(m) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor; let offset_b = f32::clamp(ink(y) * slope_b, -b, -b + 1.) * color_parameter_group_scale_factor; // An 8-bit PSD document sums the groups' 8-bit offsets, which this float node does not currently attempt to reproduce sum += Vec3::new(offset_r, offset_g, offset_b); } let rgb = Vec3::new(r, g, b); let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE); Color::from_gamma_srgb_channels(out.x, out.y, out.z, a) }); image } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))] fn posterize>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, #[default(4)] #[hard(2..)] levels: Item, ) -> Item { let mut input = input; let levels = levels.into_element() as f32; input.element_mut().adjust(|color| { color.map_gamma_rgb(|c| { // Bins as floor(c * levels) with the outputs spread evenly to white. // The sliver of slack keeps an input exactly on an edge in the upper bin despite float ties. let bin = ((c + 2e-7) * levels).floor().min(levels - 1.); bin / (levels - 1.) }) }); input } // Aims for interoperable compatibility with: // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=curv%27%20%3D%20Curves-,%27expA%27%20%3D%20Exposure,-%27vibA%27%20%3D%20Vibrance // https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Flag%20(%20%3D%20128%20)-,Exposure,-Key%20is%20%27expA // // The exposure, offset, and gamma operations follow: // https://geraldbakker.nl/psnumbers/exposure.html #[node_macro::node(category("Raster: Adjustment"), properties("exposure_properties"), shader_node(PerPixelAdjust))] fn exposure>( _: impl Ctx, #[implementations( Raster, Color, Gradient, )] #[gpu_image] input: Item, exposure: Item, offset: Item, #[default(1.)] #[range] #[hard(0.0001..)] #[soft(0.01..10)] gamma_correction: Item, ) -> Item { let mut input = input; let exposure = exposure.into_element(); let offset = offset.into_element(); let gamma_correction = gamma_correction.into_element(); // Linearizes with a 2.2 power above a straight toe of slope 1/32, the two meeting at this constant const TOE_END: f32 = 0.05568117; // 32^(-1. / 1.2) let decode = |value: f32| if value < TOE_END { value / 32. } else { value.powf(2.2) }; let encode = |linear: f32| if linear < TOE_END / 32. { linear * 32. } else { linear.powf(1. / 2.2) }; let adjust = |c: f32| { let linear = decode(c) * 2_f32.powf(exposure) + offset; encode(linear.max(0.).powf(1. / gamma_correction).min(1.)) }; input.element_mut().adjust(|color| { let [r, g, b, a] = color.to_gamma_srgb_channels(); Color::from_gamma_srgb_channels(adjust(r), adjust(g), adjust(b), a) }); 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, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, TonalRange, }; graphene_hash::impl_via_hash!( DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, NoiseType, FractalType, CellularDistanceFunction, CellularReturnType, DomainWarpType, RelativeAbsolute, SelectiveColorChoice, AdjustmentChannel, TonalRange, ); } #[cfg(all(feature = "std", test))] mod tests { use super::*; /// 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 invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5); let inverted = invert((), Item::new_from_element(color)).into_element(); let [r, g, b, a] = inverted.to_gamma_srgb_channels(); assert!((r - 0.).abs() < 1e-5 && (g - 0.75).abs() < 1e-5 && (b - 1.).abs() < 1e-5, "inverted channels were {r} {g} {b}"); assert!((a - 0.5).abs() < 1e-5, "alpha was {a}"); } /// Whether one gamma-space RGB value (0..255) ends up white at the given threshold level (0..255). fn threshold_is_white(input: [f32; 3], level: f32) -> bool { let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f32.into()); result.into_element().r() == 1. } #[test] fn threshold_compares_rec_601_luma_as_an_8_bit_level() { assert!(!threshold_is_white([200., 100., 40.], 128.)); assert!(!threshold_is_white([125., 130., 120.], 128.)); assert!(threshold_is_white([0., 255., 0.], 128.)); assert!(!threshold_is_white([255., 0., 0.], 128.)); assert!(threshold_is_white([128., 128., 128.], 128.)); assert!(!threshold_is_white([127., 127., 127.], 128.)); assert!(threshold_is_white([200., 100., 40.], 123.)); assert!(!threshold_is_white([200., 100., 40.], 124.)); } #[test] fn threshold_ties_follow_the_unrounded_fixed_point_luma() { // Half-level lumas in 0.3/0.59/0.11 stay below the level either way, and the 14-bit weights pull a whole-level red or blue luma just under it assert!(!threshold_is_white([189., 120., 0.], 128.)); assert!(!threshold_is_white([248., 90., 0.], 128.)); assert!(!threshold_is_white([135., 100., 0.], 100.)); assert!(!threshold_is_white([255., 0., 50.], 82.)); assert!(threshold_is_white([0., 200., 0.], 118.)); } /// Runs Selective Color on one gamma-space RGB value (0..255) with the given group values /// (Reds through Blacks, each cyan, magenta, yellow, black) and returns the gamma-space result on the same scale. fn run_selective_color(input: [f32; 3], mode: RelativeAbsolute, groups: [[f32; 4]; 9]) -> [f32; 3] { let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.); let g = |group: usize, component: usize| Item::new_from_element(groups[group][component]); #[rustfmt::skip] let result = selective_color( (), Item::new_from_element(pixel), mode.into(), g(0, 0), g(0, 1), g(0, 2), g(0, 3), g(1, 0), g(1, 1), g(1, 2), g(1, 3), g(2, 0), g(2, 1), g(2, 2), g(2, 3), g(3, 0), g(3, 1), g(3, 2), g(3, 3), g(4, 0), g(4, 1), g(4, 2), g(4, 3), g(5, 0), g(5, 1), g(5, 2), g(5, 3), g(6, 0), g(6, 1), g(6, 2), g(6, 3), g(7, 0), g(7, 1), g(7, 2), g(7, 3), g(8, 0), g(8, 1), g(8, 2), g(8, 3), SelectiveColorChoice::Reds.into(), ); let [r, g, b, _] = result.into_element().to_gamma_srgb_channels(); [r * 255., g * 255., b * 255.] } #[test] fn selective_color_applies_negative_values() { let mut groups = [[0.; 4]; 9]; groups[0] = [-100., 0., 0., 0.]; assert_close(run_selective_color([125., 0., 0.], RelativeAbsolute::Relative, groups), [189., 0., 0.]); assert_close(run_selective_color([120., 100., 5.], RelativeAbsolute::Relative, groups), [131., 100., 5.]); let mut groups = [[0.; 4]; 9]; groups[0] = [0., 0., 0., -100.]; assert_close(run_selective_color([110., 65., 25.], RelativeAbsolute::Absolute, groups), [136., 99., 66.]); } #[test] fn selective_color_neutrals_include_pixels_with_an_empty_channel() { let mut groups = [[0.; 4]; 9]; groups[7] = [0., -100., 0., 0.]; assert_close(run_selective_color([100., 0., 130.], RelativeAbsolute::Relative, groups), [100., 125., 130.]); assert_close(run_selective_color([100., 50., 130.], RelativeAbsolute::Relative, groups), [100., 191., 130.]); } /// Runs Posterize on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. fn run_posterize(value: f32, levels: u32) -> f32 { let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); posterize((), Item::new_from_element(pixel), levels.into()).into_element().to_gamma_srgb_channels()[0] * 255. } #[test] fn posterize_bins_by_floor_with_levels_spread_to_white() { for (value, levels, expected) in [ (84., 3, 0.), (85., 3, 127.5), (169., 3, 127.5), (170., 3, 255.), (36., 7, 0.), (37., 7, 42.5), (110., 7, 127.5), (255., 7, 255.), ] { let actual = run_posterize(value, levels); assert!((actual - expected).abs() <= 0.01, "{value} at {levels} levels: expected {expected}, got {actual}"); } } /// Runs Exposure on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale. fn run_exposure(value: f32, exposure: f32, offset: f32, gamma_correction: f32) -> f32 { let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); let result = super::exposure((), Item::new_from_element(pixel), exposure.into(), offset.into(), gamma_correction.into()); result.into_element().to_gamma_srgb_channels()[0] * 255. } #[test] fn exposure_linearizes_through_the_toe_and_power_curve() { for (value, exposure, offset, gamma_correction, expected) in [ (1., 1., 0., 1., 2.), (8., 1., 0., 1., 15.), (16., 1., 0., 1., 22.), (128., 1., 0., 1., 175.), (200., 1., 0., 1., 255.), (1., 0., 0., 2., 33.), (16., 0., 0., 2., 64.), (128., 0., 0., 2., 181.), (200., 0., 0., 2., 226.), (0., -2., 0.2, 1.5, 157.), (100., -2., 0.2, 1.5, 164.), (200., -2., 0.2, 1.5, 185.), (100., 0., -0.25, 1., 0.), (200., 0., -0.25, 1., 155.), ] { let actual = run_exposure(value, exposure, offset, gamma_correction); assert!( (actual - expected).abs() <= 1., "{value} at exposure {exposure}, offset {offset}, gamma {gamma_correction}: expected {expected}, got {actual}" ); } } #[test] fn exposure_clamps_negative_offsets_before_the_gamma_power() { assert_eq!(run_exposure(50., 0., -0.5, 2.), 0.); } /// Runs Color Balance on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale. fn run_color_balance(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.] } #[test] fn color_balance_midtones_are_a_gamma_with_a_toe() { let none = [0., 0., 0.]; assert_close(run_color_balance([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]); assert_close(run_color_balance([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]); assert_close(run_color_balance([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]); assert_close(run_color_balance([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]); assert_close(run_color_balance([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]); } #[test] fn color_balance_shadows_and_highlights_move_the_end_points() { let none = [0., 0., 0.]; assert_close(run_color_balance([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]); assert_close(run_color_balance([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]); assert_close(run_color_balance([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]); assert_close(run_color_balance([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]); assert_close(run_color_balance([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]); } #[test] fn color_balance_preserve_luminosity_makes_sliders_relative() { let none = [0., 0., 0.]; assert_close(run_color_balance([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]); assert_close(run_color_balance([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]); assert_close(run_color_balance([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]); assert_close(run_color_balance([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]); } #[test] fn color_balance_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_color_balance([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false); assert_close(result, [124., 120., 164.]); } }