diff --git a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs index db541dc4ea..75483971a8 100644 --- a/editor/src/messages/layout/utility_types/widgets/input_widgets.rs +++ b/editor/src/messages/layout/utility_types/widgets/input_widgets.rs @@ -695,11 +695,9 @@ pub struct SpectrumInput { #[cfg_attr(feature = "wasm", derive(tsify::Tsify))] #[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)] pub struct SpectrumMarker { - /// Position of the marker along the spectrum track, normally from 0 to 1. A shifted or stretched non-cyclic ramp can - /// place it outside that range, where the track draws only the markers falling within its visible span. + /// Position along the track, normally 0..1. A shifted or stretched non-cyclic ramp can push it outside, where it is not drawn. position: f64, - /// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true. - /// The last marker's value controls the wrapped interval when `track_cyclic` is set, and is otherwise ignored. + /// Midpoint (0..1) of the interval to the next marker, used only with `show_midpoints`. The last marker's midpoint spans the wrap of a cyclic track, or is otherwise ignored. midpoint: f64, /// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`], /// discarding any transparency so the handle always shows the RGB that steers the interpolation. @@ -708,6 +706,9 @@ pub struct SpectrumMarker { /// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers. #[serde(rename = "dashedToNext")] dashed_to_next: bool, + /// Whether this marker follows its neighbors instead of bounding them, so they may drag past its drawn position. + #[serde(rename = "betweenNeighbors")] + between_neighbors: bool, } impl SpectrumMarker { @@ -718,9 +719,15 @@ impl SpectrumMarker { midpoint, handle_color_css, dashed_to_next: false, + between_neighbors: false, } } + pub fn between_neighbors(mut self) -> Self { + self.between_neighbors = true; + self + } + pub fn dash_to_next(mut self) -> Self { self.dashed_to_next = true; self 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 dfb06abf79..1bcaa67906 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -1399,17 +1399,63 @@ pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodeProp pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec { use graphene_std::raster::levels::*; + let mut channel_info = ParameterWidgetsInfo::new(node_id, ChannelInput, true, context); + channel_info.exposable = false; + let channel = enum_choice::().for_socket(channel_info).property_row(); + + let channel_value = match get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(ChannelInput).cloned()) { + Some(TaggedValue::AdjustmentChannel(channel)) => channel, + _ => AdjustmentChannel::Rgb, + }; + let [shadows, midtones, highlights, output_minimums, output_maximums]: [ParameterRef; 5] = match channel_value { + AdjustmentChannel::Rgb => [ + ShadowsInput.into(), + MidtonesInput.into(), + HighlightsInput.into(), + OutputMinimumsInput.into(), + OutputMaximumsInput.into(), + ], + AdjustmentChannel::Red => [ + RedShadowsInput.into(), + RedMidtonesInput.into(), + RedHighlightsInput.into(), + RedOutputMinimumsInput.into(), + RedOutputMaximumsInput.into(), + ], + AdjustmentChannel::Green => [ + GreenShadowsInput.into(), + GreenMidtonesInput.into(), + GreenHighlightsInput.into(), + GreenOutputMinimumsInput.into(), + GreenOutputMaximumsInput.into(), + ], + AdjustmentChannel::Blue => [ + BlueShadowsInput.into(), + BlueMidtonesInput.into(), + BlueHighlightsInput.into(), + BlueOutputMinimumsInput.into(), + BlueOutputMaximumsInput.into(), + ], + AdjustmentChannel::Alpha => [ + AlphaShadowsInput.into(), + AlphaMidtonesInput.into(), + AlphaHighlightsInput.into(), + AlphaOutputMinimumsInput.into(), + AlphaOutputMaximumsInput.into(), + ], + }; + let input_range_params = [ - SpectrumSectionParam::new(ShadowsInput, Color::BLACK, 0., MarkerScale::Percent), - SpectrumSectionParam::new(MidtonesInput, Color::MIDDLE_GRAY, 50., MarkerScale::Percent), - SpectrumSectionParam::new(HighlightsInput, Color::WHITE, 100., MarkerScale::Percent), + SpectrumSectionParam::new(shadows, Color::BLACK, 0., MarkerScale::Percent), + SpectrumSectionParam::new(midtones, Color::MIDDLE_GRAY, 1., MarkerScale::Gamma).between_neighbors(), + SpectrumSectionParam::new(highlights, Color::WHITE, 100., MarkerScale::Percent), ]; let output_range_params = [ - SpectrumSectionParam::new(OutputMinimumsInput, Color::BLACK, 0., MarkerScale::Percent), - SpectrumSectionParam::new(OutputMaximumsInput, Color::WHITE, 100., MarkerScale::Percent), + SpectrumSectionParam::new(output_minimums, Color::BLACK, 0., MarkerScale::Percent), + SpectrumSectionParam::new(output_maximums, Color::WHITE, 100., MarkerScale::Percent), ]; - let mut layout = Vec::with_capacity(5); + let mut layout = vec![channel]; build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout); build_shared_spectrum_section(node_id, context, &bw_track(), &output_range_params, &mut layout); layout @@ -1459,6 +1505,8 @@ struct SpectrumSectionParam { scale: MarkerScale, /// Whether a dashed line joins the marker to the next parameter's marker. dash_to_next: bool, + /// Whether the marker takes its scale position within the span between its neighbors rather than the whole track, following them as they move. + between_neighbors: bool, } impl SpectrumSectionParam { @@ -1469,9 +1517,15 @@ impl SpectrumSectionParam { default_value, scale, dash_to_next: false, + between_neighbors: false, } } + fn between_neighbors(mut self) -> Self { + self.between_neighbors = true; + self + } + fn dash_to_next(mut self) -> Self { self.dash_to_next = true; self @@ -1507,6 +1561,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let mut marker_default_positions = Vec::new(); let mut marker_scales = Vec::new(); let mut marker_positions = Vec::new(); + let mut marker_between = Vec::new(); let mut marker_colors_and_links = Vec::new(); for (i, param) in params.iter().enumerate() { let (exposed, value) = exposure_and_value[i]; @@ -1518,20 +1573,41 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo marker_input_indices.push(param.parameter.input_index); marker_default_positions.push(param.scale.position(param.default_value)); marker_scales.push(param.scale); + marker_between.push(param.between_neighbors); marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker)); } - // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence - for i in 1..marker_positions.len() { - marker_positions[i] = marker_positions[i].max(marker_positions[i - 1]); + // Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence. + // A marker placed between its neighbors bounds nothing here and instead takes its scale position within their settled span. + let mut floor = 0.; + for (position, &between) in marker_positions.iter_mut().zip(&marker_between) { + if between { + continue; + } + *position = position.max(floor); + floor = *position; + } + for i in 0..marker_positions.len() { + if marker_between[i] { + let left = if i == 0 { 0. } else { marker_positions[i - 1] }; + let right = marker_positions.get(i + 1).copied().unwrap_or(1.); + marker_positions[i] = left + marker_positions[i] * (right - left); + } } let spectrum_markers: Vec = marker_positions .iter() .zip(&marker_colors_and_links) - .map(|(&position, &(handle_color, dashed))| { - let marker = SpectrumMarker::new(position, 0.5, handle_color); - if dashed { marker.dash_to_next() } else { marker } + .zip(&marker_between) + .map(|((&position, &(handle_color, dashed)), &between)| { + let mut marker = SpectrumMarker::new(position, 0.5, handle_color); + if dashed { + marker = marker.dash_to_next(); + } + if between { + marker = marker.between_neighbors(); + } + marker }) .collect(); @@ -1550,21 +1626,35 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo let marker_default_positions = marker_default_positions.clone(); let marker_scales = marker_scales.clone(); let marker_positions = marker_positions.clone(); + let marker_between = marker_between.clone(); move |update: &SpectrumInputUpdate| { let i = match update { SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize, _ => return Message::NoOp, }; - let (Some(&input_index), Some(&scale), Some(&default_position)) = (marker_input_indices.get(i), marker_scales.get(i), marker_default_positions.get(i)) else { + let (Some(&input_index), Some(&scale), Some(&between), Some(&default_position)) = + (marker_input_indices.get(i), marker_scales.get(i), marker_between.get(i), marker_default_positions.get(i)) + else { return Message::NoOp; }; - let left = if i == 0 { 0. } else { marker_positions[i - 1] }; - let right = marker_positions.get(i + 1).copied().unwrap_or(1.); + + // The span the marker's scale maps onto: its neighbors' positions when placed between them, otherwise the track between the + // nearest markers that bound it, which a marker placed between its neighbors never does + let bounding = |j: usize| between || !marker_between[j]; + let left = (0..i).rev().find(|&j| bounding(j)).map_or(0., |j| marker_positions[j]); + let right = (i + 1..marker_positions.len()).find(|&j| bounding(j)).map_or(1., |j| marker_positions[j]); let scale_position = match update { + SpectrumInputUpdate::MoveMarker { position, .. } if between => { + let span = right - left; + if span <= f64::EPSILON { + return Message::NoOp; + } + ((position - left) / span).clamp(0., 1.) + } SpectrumInputUpdate::MoveMarker { position, .. } => *position, // A default that would cross a neighbor falls back to the midpoint between them - SpectrumInputUpdate::ResetMarker { .. } if (left..=right).contains(&default_position) => default_position, + SpectrumInputUpdate::ResetMarker { .. } if between || (left..=right).contains(&default_position) => default_position, SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2., _ => return Message::NoOp, }; diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index 00975c54d6..a1373975d4 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -2196,6 +2196,31 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId], inputs_count = 3; } + // Levels' Midtones became the gamma value it encoded, and each channel gained its own record after the composite one + if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::levels::IDENTIFIER) && inputs_count == 6 { + let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); + document.network_interface.replace_implementation(node_id, network_path, &mut node_template); + let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?; + let output_level = |index: usize, default: f32| match old_inputs.get(index).and_then(|input| input.as_value()) { + Some(TaggedValue::F32(percent)) => percent / 100., + _ => default, + }; + let (output_minimums, output_maximums) = (output_level(4, 0.), output_level(5, 1.)); + for (index, input) in old_inputs.iter().take(6).enumerate() { + let input = match (index, input.as_value()) { + (2, Some(TaggedValue::F32(percent))) => { + // The old node's midtones-to-gamma mapping, from https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels + let midtones = output_minimums + (output_maximums - output_minimums) * percent / 100.; + let gamma = if midtones < 0.5 { 1. + 9. * (1. - midtones * 2.) } else { ((1. - midtones) * 2.).max(0.01) }; + NodeInput::value(TaggedValue::F32(gamma.clamp(0.01, 9.99)), input.is_exposed()) + } + _ => input.clone(), + }; + document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path); + } + inputs_count = 27; + } + if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 { let mut node_template = resolve_document_node_type(&reference)?.default_node_template(); document.network_interface.replace_implementation(node_id, network_path, &mut node_template); diff --git a/frontend/src/components/widgets/inputs/SpectrumInput.svelte b/frontend/src/components/widgets/inputs/SpectrumInput.svelte index 43aa6007dc..122d4ef9d8 100644 --- a/frontend/src/components/widgets/inputs/SpectrumInput.svelte +++ b/frontend/src/components/widgets/inputs/SpectrumInput.svelte @@ -98,11 +98,19 @@ function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number { // Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors if (allowReorder && allowSelect) return position; - const lower = markers[first - 1]?.position ?? 0; - const upper = (markers[last + 1]?.position ?? 1) - spacing; + const lower = neighborBound(first, -1) ?? 0; + const upper = (neighborBound(last, 1) ?? 1) - spacing; return Math.max(lower, Math.min(upper, position)); } + // The position of the nearest marker past `index` in the direction of `step` that bounds others, skipping any placed between its neighbors since those follow them instead + function neighborBound(index: number, step: -1 | 1): number | undefined { + for (let i = index + step; i >= 0 && i < markers.length; i += step) { + if (!markers[i].betweenNeighbors) return markers[i].position; + } + return undefined; + } + // The spans from each marker passing `linked` to its successor function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] { const spans: { index: number; left: number; width: number }[] = []; diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 6f7d289398..7fe8a69d75 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -328,88 +328,222 @@ pub enum AdjustmentChannel { Alpha, } +/// One Levels record in the node's units: percentage input and output points and the gamma value. +#[derive(Clone, Copy)] +struct LevelsRecord { + shadows: f32, + midtones: f32, + highlights: f32, + output_minimums: f32, + output_maximums: f32, +} + +/// A record's input curve followed by its output range. +#[derive(Clone, Copy)] +struct LevelsStage { + curve: LevelsCurve, + output_minimum: f32, + output_maximum: f32, +} + +impl LevelsRecord { + fn new(shadows: f32, midtones: f32, highlights: f32, output_minimums: f32, output_maximums: f32) -> Self { + Self { + shadows, + midtones, + highlights, + output_minimums, + output_maximums, + } + } + + fn stage(&self, gamma: f32) -> LevelsStage { + LevelsStage { + curve: LevelsCurve::from_points(self.shadows * 2.55, self.highlights * 2.55, gamma), + output_minimum: self.output_minimums / 100., + output_maximum: self.output_maximums / 100., + } + } +} + +impl LevelsStage { + fn apply(&self, value: f32) -> f32 { + self.curve.apply(value) * (self.output_maximum - self.output_minimum) + self.output_minimum + } +} + +/// A channel's record followed by the composite record. +#[derive(Clone, Copy)] +struct LevelsChain { + first: LevelsStage, + second: LevelsStage, + two_stages: bool, +} + +impl LevelsChain { + fn new(channel: LevelsRecord, composite: LevelsRecord) -> Self { + // For PSD interop, two power functions with nothing between them (the composite's input points and the + // channel's output range at their defaults) merge into one curve with the product of the gammas, toe included + let nothing_between = composite.shadows == 0. && composite.highlights == 100. && channel.output_minimums == 0. && channel.output_maximums == 100.; + if nothing_between { + let merged = LevelsRecord { + output_minimums: composite.output_minimums, + output_maximums: composite.output_maximums, + ..channel + }; + let stage = merged.stage(channel.midtones * composite.midtones); + Self { + first: stage, + second: stage, + two_stages: false, + } + } else { + Self { + first: channel.stage(channel.midtones), + second: composite.stage(composite.midtones), + two_stages: true, + } + } + } + + fn apply(&self, value: f32) -> f32 { + let value = self.first.apply(value); + if self.two_stages { self.second.apply(value) } else { value } + } +} + // 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, - )] + #[implementations(Raster, Color, Gradient)] #[gpu_image] image: Item, #[default(0.)] shadows: Item, - #[default(50.)] midtones: Item, + #[default(1.)] midtones: Item, #[default(100.)] highlights: Item, #[default(0.)] output_minimums: Item, #[default(100.)] output_maximums: Item, + #[name("(Red) Shadows")] + #[default(0.)] + red_shadows: Item, + #[name("(Red) Midtones")] + #[default(1.)] + red_midtones: Item, + #[name("(Red) Highlights")] + #[default(100.)] + red_highlights: Item, + #[name("(Red) Output Minimums")] + #[default(0.)] + red_output_minimums: Item, + #[name("(Red) Output Maximums")] + #[default(100.)] + red_output_maximums: Item, + #[name("(Green) Shadows")] + #[default(0.)] + green_shadows: Item, + #[name("(Green) Midtones")] + #[default(1.)] + green_midtones: Item, + #[name("(Green) Highlights")] + #[default(100.)] + green_highlights: Item, + #[name("(Green) Output Minimums")] + #[default(0.)] + green_output_minimums: Item, + #[name("(Green) Output Maximums")] + #[default(100.)] + green_output_maximums: Item, + #[name("(Blue) Shadows")] + #[default(0.)] + blue_shadows: Item, + #[name("(Blue) Midtones")] + #[default(1.)] + blue_midtones: Item, + #[name("(Blue) Highlights")] + #[default(100.)] + blue_highlights: Item, + #[name("(Blue) Output Minimums")] + #[default(0.)] + blue_output_minimums: Item, + #[name("(Blue) Output Maximums")] + #[default(100.)] + blue_output_maximums: Item, + #[name("(Alpha) Shadows")] + #[default(0.)] + alpha_shadows: Item, + #[name("(Alpha) Midtones")] + #[default(1.)] + alpha_midtones: Item, + #[name("(Alpha) Highlights")] + #[default(100.)] + alpha_highlights: Item, + #[name("(Alpha) Output Minimums")] + #[default(0.)] + alpha_output_minimums: Item, + #[name("(Alpha) Output Maximums")] + #[default(100.)] + alpha_output_maximums: Item, + _channel: 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(); + let composite = LevelsRecord::new( + shadows.into_element(), + midtones.into_element(), + highlights.into_element(), + output_minimums.into_element(), + output_maximums.into_element(), + ); + let red = LevelsChain::new( + LevelsRecord::new( + red_shadows.into_element(), + red_midtones.into_element(), + red_highlights.into_element(), + red_output_minimums.into_element(), + red_output_maximums.into_element(), + ), + composite, + ); + let green = LevelsChain::new( + LevelsRecord::new( + green_shadows.into_element(), + green_midtones.into_element(), + green_highlights.into_element(), + green_output_minimums.into_element(), + green_output_maximums.into_element(), + ), + composite, + ); + let blue = LevelsChain::new( + LevelsRecord::new( + blue_shadows.into_element(), + blue_midtones.into_element(), + blue_highlights.into_element(), + blue_output_minimums.into_element(), + blue_output_maximums.into_element(), + ), + composite, + ); + + // Alpha stands apart from the composite record that the three color channels pass through + let alpha = LevelsRecord::new( + alpha_shadows.into_element(), + alpha_midtones.into_element(), + alpha_highlights.into_element(), + alpha_output_minimums.into_element(), + alpha_output_maximums.into_element(), + ); + let alpha = alpha.stage(alpha.midtones); image.element_mut().adjust(|color| { // Levels math operates in gamma space - let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels(); + let [r, g, 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) + Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), alpha.apply(a)) }); image } @@ -1469,6 +1603,70 @@ mod tests { } } + /// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points + /// on one gamma-space gray value (0..255), returning the red and green results on the same scale. + fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] { + let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.); + let percent = |level: f32| level / 2.55; + let result = levels( + (), + Item::new_from_element(pixel), + percent(composite[0]).into(), + composite[2].into(), + percent(composite[1]).into(), + percent(composite[3]).into(), + percent(composite[4]).into(), + percent(red[0]).into(), + red[2].into(), + percent(red[1]).into(), + percent(red[3]).into(), + percent(red[4]).into(), + 0_f32.into(), + 1_f32.into(), + 100_f32.into(), + 0_f32.into(), + 100_f32.into(), + 0_f32.into(), + 1_f32.into(), + 100_f32.into(), + 0_f32.into(), + 100_f32.into(), + 0_f32.into(), + 1_f32.into(), + 100_f32.into(), + 0_f32.into(), + 100_f32.into(), + AdjustmentChannel::Rgb.into(), + ); + let [r, g, _, _] = result.into_element().to_gamma_srgb_channels(); + [r * 255., g * 255.] + } + + #[test] + fn levels_records_merge_into_one_gamma_only_when_nothing_lies_between() { + const DEFAULT: [f32; 5] = [0., 255., 1., 0., 255.]; + for (value, composite, red, expected_red, expected_green) in [ + // Two gammas with nothing between them act as one gamma of 2.25, toe included + (5., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 23., 14.), + (25., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 89., 54.), + (100., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 168., 137.), + // A black point in each record keeps them as two curves + (40., [30., 255., 1.5, 0., 255.], [20., 255., 1.5, 0., 255.], 49., 28.), + (100., [30., 255., 1.5, 0., 255.], [20., 255., 1.5, 0., 255.], 144., 117.), + // Input and output points only + (100., [30., 220., 1., 0., 255.], [50., 255., 1., 0., 200.], 26., 94.), + (150., [30., 220., 1., 0., 255.], [50., 255., 1., 0., 200.], 91., 161.), + // A pure channel gamma under a composite with points stays a separate stage + (5., [0., 200., 1.2, 10., 255.], [0., 255., 3., 0., 255.], 70., 21.), + (50., [0., 200., 1.2, 10., 255.], [0., 255., 3., 0., 255.], 201., 88.), + (128., DEFAULT, DEFAULT, 128., 128.), + ] { + let [red_actual, green_actual] = run_levels(value, composite, red); + assert!((red_actual - expected_red).abs() <= 1.5, "{value} red: expected {expected_red}, got {red_actual}"); + assert!((green_actual - expected_green).abs() <= 1.5, "{value} green: expected {expected_green}, got {green_actual}"); + } + } + #[test] fn invert_flips_straight_channels_and_keeps_alpha() { let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5);