Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs (#4149)

* Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs

* Code review fixes
This commit is contained in:
Keavon Chambers
2026-05-14 22:48:33 -07:00
committed by GitHub
parent 456a7c868d
commit a56746c6bf
67 changed files with 1210 additions and 941 deletions

View File

@@ -2,6 +2,7 @@ use super::DocumentNode;
use crate::application_io::PlatformEditorApi;
use crate::proto::{Any as DAny, FutureAny};
use brush_nodes::brush_stroke::BrushStroke;
use core_types::color::SRGBA8;
use core_types::list::List;
use core_types::transform::Footprint;
use core_types::uuid::NodeId;
@@ -464,7 +465,7 @@ impl TaggedValue {
// String syntax (e.g. "000000ff")
if input.starts_with('"') && input.ends_with('"') {
let hex = input.trim().trim_matches('"').trim().trim_start_matches('#');
let color = Color::from_hex_str(hex);
let color = SRGBA8::from_hex_str(hex).map(Color::from);
if color.is_none() {
log::error!("Invalid default value color string: {input}");
}

View File

@@ -20,6 +20,7 @@ no-std-types = { workspace = true, features = ["std"] }
graphene-hash = { workspace = true, features = ["derive"] }
# Workspace dependencies
color = { workspace = true }
bitflags = { workspace = true }
bytemuck = { workspace = true }
node-macro = { workspace = true }

View File

@@ -102,3 +102,37 @@ pub fn migrate_to_f64_array<'de, D: serde::Deserializer<'de>>(deserializer: D) -
F64ArrayFormat::List(list) => list.element,
})
}
/// Parse a CSS color string (named color, hex, `rgb(...)`, `hsl(...)`, etc.) into a linear-light [`Color`] using the `color` crate's CSS Color 4 parser.
/// Tries the input as-is first (catches CSS named colors like `red`, `rgb(...)`, and well-formed hex like `#abcdef`), then falls back to treating the input as bare hex with length-based expansion to a CSS-parseable form:
/// - 1 char `f` → `#fff` (CSS 3-char shorthand)
/// - 2 char `ab` → `#ababab` (repeated to 6 chars)
/// - 4 char `abcd` → `#00abcd` (left-padded with `00`)
/// - 5 char `abcde` → `#0abcde` (left-padded with `0`)
/// - 3, 6, 8 char inputs are passed through with a `#` prefix.
pub fn parse_css_color(input: &str) -> Option<crate::Color> {
let trimmed = input.trim();
let parsed = color::parse_color(trimmed).ok().or_else(|| {
let bare = trimmed.strip_prefix('#').unwrap_or(trimmed);
if bare.is_empty() || !bare.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
let expanded = match bare.len() {
1 => bare.repeat(3),
2 => bare.repeat(3),
4 => format!("00{bare}"),
5 => format!("0{bare}"),
_ => bare.to_string(),
};
let candidate = format!("#{expanded}");
// Avoid retrying the exact same string we just failed to parse.
(candidate != trimmed).then(|| color::parse_color(&candidate).ok()).flatten()
})?;
let srgb: color::AlphaColor<color::Srgb> = parsed.to_alpha_color();
let [red, green, blue, alpha] = srgb.components;
// Reject out-of-gamut values that `color::parse_color` accepts for newer CSS syntax (e.g., `rgb(300 -50 200)`).
let in_gamut = alpha <= 1. && ![red, green, blue, alpha].iter().any(|c| c.is_sign_negative() || !c.is_finite());
in_gamut.then(|| crate::Color::from_gamma_srgb_channels(red, green, blue, alpha))
}

View File

@@ -92,17 +92,112 @@ impl Alpha for RGBA16F {
impl Pixel for RGBA16F {}
/// An sRGB color with 8-bit unassociated-alpha channels. Used as the wire format at the DOM boundary:
/// bijective with hex codes, byte-identical to CSS/SVG/PNG/peniko conventions. Internal computations use
/// the linear-light [`Color`] type. Convert via [`From<SRGBA8> for Color`] and [`From<Color> for SRGBA8`].
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)]
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Pod, Zeroable)]
pub struct SRGBA8 {
red: u8,
green: u8,
blue: u8,
alpha: u8,
pub red: u8,
pub green: u8,
pub blue: u8,
pub alpha: u8,
}
impl SRGBA8 {
pub const TRANSPARENT: Self = Self::new(0, 0, 0, 0);
pub const BLACK: Self = Self::new(0, 0, 0, 255);
pub const WHITE: Self = Self::new(255, 255, 255, 255);
/// Construct from raw 8-bit channels. Alpha is unassociated (not premultiplied), matching CSS/SVG/PNG convention.
#[inline(always)]
pub const fn new(red: u8, green: u8, blue: u8, alpha: u8) -> Self {
Self { red, green, blue, alpha }
}
/// Construct an opaque (alpha = 255) color from raw 8-bit RGB channels.
#[inline(always)]
pub const fn new_opaque(red: u8, green: u8, blue: u8) -> Self {
Self::new(red, green, blue, 255)
}
/// Parse `RRGGBB` or `RRGGBBAA` (with or without a leading `#`). Returns `None` for any other format.
/// For full CSS Color 4 parsing (named colors, shorthand hex, `rgb(...)`, `hsl(...)`), parse in the caller and construct via [`Self::new`].
#[cfg(feature = "std")]
pub fn from_hex_str(hex: &str) -> Option<Self> {
let hex = hex.trim().trim_start_matches('#');
if hex.len() != 6 && hex.len() != 8 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()?;
let green = u8::from_str_radix(&hex[2..4], 16).ok()?;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()?;
let alpha = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).ok()? } else { 255 };
Some(Self::new(red, green, blue, alpha))
}
/// `rrggbb` (lowercase, no `#` prefix, alpha discarded). Use where alpha is specified separately, e.g. SVG `fill="#..." fill-opacity="..."`.
#[cfg(feature = "std")]
pub fn to_rgb_hex(self) -> String {
format!("{:02x}{:02x}{:02x}", self.red, self.green, self.blue)
}
/// `rrggbbaa` (lowercase, no `#` prefix).
#[cfg(feature = "std")]
pub fn to_rgba_hex(self) -> String {
format!("{:02x}{:02x}{:02x}{:02x}", self.red, self.green, self.blue, self.alpha)
}
/// `#rrggbb` if fully opaque, `#rrggbbaa` otherwise. Suitable for direct insertion into a CSS property or SVG attribute.
#[cfg(feature = "std")]
pub fn to_css_hex(self) -> String {
if self.alpha == 255 {
format!("#{}", self.to_rgb_hex())
} else {
format!("#{}", self.to_rgba_hex())
}
}
/// Returns [`Self::BLACK`] or [`Self::WHITE`], whichever gives more legible text against this color
/// (alpha composited over white in gamma space, WCAG-style relative-luminance threshold).
pub fn contrasting_text_color(self) -> Self {
// Composite over white in gamma space, then convert to linear for the luminance test.
let r = self.red as f32 / 255.;
let g = self.green as f32 / 255.;
let b = self.blue as f32 / 255.;
let a = self.alpha as f32 / 255.;
let composited = Color::from_gamma_srgb_channels(1. - a + r * a, 1. - a + g * a, 1. - a + b * a, 1.);
let luminance = composited.luminance_rec_709();
// WCAG-derived perceptual midpoint between black and white (~0.179)
let threshold = (1.05_f32 * 0.05).sqrt() - 0.05;
if luminance > threshold { Self::BLACK } else { Self::WHITE }
}
}
impl From<[u8; 4]> for SRGBA8 {
#[inline(always)]
fn from(bytes: [u8; 4]) -> Self {
let [red, green, blue, alpha] = bytes;
Self::new(red, green, blue, alpha)
}
}
impl From<SRGBA8> for [u8; 4] {
#[inline(always)]
fn from(c: SRGBA8) -> Self {
let SRGBA8 { red, green, blue, alpha } = c;
[red, green, blue, alpha]
}
}
/// Lets `Image<SRGBA8>` cross the wasm boundary as gamma bytes, since `Color` (linear-light) isn't exposed with Tsify.
impl Pixel for SRGBA8 {}
impl From<Color> for SRGBA8 {
#[inline(always)]
fn from(c: Color) -> Self {
@@ -127,53 +222,6 @@ impl From<SRGBA8> for Color {
}
}
impl Luminance for SRGBA8 {
type LuminanceChannel = f32;
#[inline(always)]
fn luminance(&self) -> f32 {
// TODO: verify this is correct for sRGB
0.2126 * self.red() + 0.7152 * self.green() + 0.0722 * self.blue()
}
}
impl RGB for SRGBA8 {
type ColorChannel = f32;
#[inline(always)]
fn red(&self) -> f32 {
self.red as f32 / 255.
}
#[inline(always)]
fn green(&self) -> f32 {
self.green as f32 / 255.
}
#[inline(always)]
fn blue(&self) -> f32 {
self.blue as f32 / 255.
}
}
impl Rec709Primaries for SRGBA8 {}
impl SRGB for SRGBA8 {}
impl Alpha for SRGBA8 {
type AlphaChannel = f32;
#[inline(always)]
fn alpha(&self) -> f32 {
self.alpha as f32 / 255.
}
const TRANSPARENT: Self = SRGBA8 { red: 0, green: 0, blue: 0, alpha: 0 };
fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self {
let alpha = alpha * 255.;
let mut result = *self;
result.alpha = (alpha * self.alpha()) as u8;
result
}
}
impl Pixel for SRGBA8 {}
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
@@ -216,9 +264,13 @@ impl Pixel for Luma {}
/// Internally alpha is stored as `f32` that ranges from `0.0` (transparent) to `1.0` (opaque).
/// The other components (RGB) are stored as `f32` that range from `0.0` up to `f32::MAX`,
/// the values encode the brightness of each channel proportional to the light intensity in cd/m² (nits) in HDR, and `0.0` (black) to `1.0` (white) in SDR color.
/// Linear-light sRGB color with `f32` channels (alpha unassociated for swatch/UI colors, associated/premultiplied for pixel data inside [`Image<Color>`]).
///
/// Channels range from `0.0` to `f32::MAX`, encoding brightness proportional to light intensity (cd/m² nits in HDR, or `0..=1` mapped to white for SDR).
///
/// Anything crossing the Wasm/JS boundary must go through [`SRGBA8`] instead.
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny, serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "std", derive(graphene_hash::CacheHash))]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable, BufferStruct)]
pub struct Color {
@@ -231,6 +283,52 @@ pub struct Color {
// `f32` channels mean `Color` doesn't qualify for a derived `Eq`, but in practice we never store NaN here, and the renderer's `HashMap<CacheHashWrapper<Image<Color>>, _>` deduplication needs `Color: Eq` to propagate up through the wrapper.
impl Eq for Color {}
// TODO: Eventually remove this migration document upgrade code
#[cfg(feature = "std")]
impl serde::Serialize for Color {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
// Persist linear-light floats directly and tag with `"linear": true` so legacy gamma-encoded values (which lack this marker) can be detected and upgraded on load.
let mut state = serializer.serialize_struct("Color", 5)?;
state.serialize_field("red", &self.red)?;
state.serialize_field("green", &self.green)?;
state.serialize_field("blue", &self.blue)?;
state.serialize_field("alpha", &self.alpha)?;
// TODO: Remove the `linear` marker when switching to the new document format and Ctrl-C node serialization format
state.serialize_field("linear", &true)?;
state.end()
}
}
// TODO: Eventually remove this migration document upgrade code
#[cfg(feature = "std")]
impl<'de> serde::Deserialize<'de> for Color {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
// Documents from before the linear-storage migration lack the `linear` marker and stored gamma-encoded floats; convert them on load.
#[derive(serde::Deserialize)]
struct MigrationColor {
red: f32,
green: f32,
blue: f32,
alpha: f32,
#[serde(default)]
// TODO: Remove the `linear` marker when switching to the new document format and Ctrl-C node serialization format
linear: bool,
}
let raw = MigrationColor::deserialize(deserializer)?;
Ok(if raw.linear {
Color {
red: raw.red,
green: raw.green,
blue: raw.blue,
alpha: raw.alpha,
}
} else {
Color::from_gamma_srgb_channels(raw.red, raw.green, raw.blue, raw.alpha)
})
}
}
impl RGB for Color {
type ColorChannel = f32;
#[inline(always)]
@@ -266,11 +364,14 @@ impl AlphaMut for Color {
impl Pixel for Color {
#[cfg(feature = "std")]
fn to_bytes(&self) -> Vec<u8> {
self.to_rgba8_srgb().to_vec()
let SRGBA8 { red, green, blue, alpha } = (*self).into();
[red, green, blue, alpha].to_vec()
}
fn from_bytes(bytes: &[u8]) -> Self {
Color::from_rgba8_srgb(bytes[0], bytes[1], bytes[2], bytes[3])
// `Image<Color>` pixel convention is linear-light with associated (premultiplied) alpha.
let srgba = SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]);
Color::from(srgba).apply_opacity(bytes[3] as f32 / 255.)
}
fn byte_size() -> usize {
4
@@ -372,56 +473,26 @@ impl Color {
Some(color)
}
/// Return an opaque `Color` from given `f32` RGB channels.
/// Construct an opaque `Color` from `f32` RGB channels, with no value validation (use [`Self::from_rgbaf32`] for validation).
#[inline(always)]
pub const fn from_rgbf32_unchecked(red: f32, green: f32, blue: f32) -> Color {
Color { red, green, blue, alpha: 1. }
}
/// Return an opaque `Color` from given `f32` RGB channels.
/// Construct a `Color` from `f32` RGBA channels, with no value validation (use [`Self::from_rgbaf32`] for validation).
#[inline(always)]
pub const fn from_rgbaf32_unchecked(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color { red, green, blue, alpha }
}
/// Return an opaque `Color` from given `f32` RGB channels.
/// Construct a `Color` from unassociated (straight) RGBA channels, premultiplying the RGB channels by alpha.
#[inline(always)]
pub fn from_unassociated_alpha(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
pub fn new_from_unassociated_rgba(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color::from_rgbaf32_unchecked(red * alpha, green * alpha, blue * alpha, alpha)
}
/// Return an opaque SDR `Color` given RGB channels from `0` to `255`, premultiplied by alpha.
///
/// # Examples
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgb8_srgb(0x72, 0x67, 0x62);
/// let color2 = Color::from_rgba8_srgb(0x72, 0x67, 0x62, 0xFF);
/// assert_eq!(color, color2)
/// ```
#[inline(always)]
pub fn from_rgb8_srgb(red: u8, green: u8, blue: u8) -> Color {
Color::from_rgba8_srgb(red, green, blue, 255)
}
// TODO: Should this be premult?
/// Return an SDR `Color` given RGBA channels from `0` to `255`, premultiplied by alpha.
///
/// # Examples
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgba8_srgb(0x72, 0x67, 0x62, 0x61);
/// ```
#[inline(always)]
pub fn from_rgba8_srgb(red: u8, green: u8, blue: u8, alpha: u8) -> Color {
let red = red as f32 / 255.;
let green = green as f32 / 255.;
let blue = blue as f32 / 255.;
let alpha = alpha as f32 / 255.;
Color { red, green, blue, alpha }.to_linear_srgb().map_rgb(|channel| channel * alpha)
}
/// Create a [Color] from a hue, saturation, lightness, and alpha (all between 0 and 1)
/// Create a linear-light `Color` from HSL coordinates (all between 0 and 1).
/// HSL is defined on sRGB display values, so the RGB produced by the HSL math is gamma-encoded and decoded to linear before being wrapped in `Color`.
///
/// # Examples
/// ```
@@ -460,10 +531,11 @@ impl Color {
map_channel(&mut green, temp2, temp1);
map_channel(&mut blue, temp2, temp1);
Color { red, green, blue, alpha }
Color::from_gamma_srgb_channels(red, green, blue, alpha)
}
/// Create a [Color] from hue, saturation, value, and alpha (all between 0 and 1).
/// Create a linear-light `Color` from HSV coordinates (all between 0 and 1).
/// HSV is defined on sRGB display values, so the RGB produced by the HSV math is gamma-encoded and decoded to linear before being wrapped in `Color`.
pub fn from_hsva(hue: f32, saturation: f32, value: f32, alpha: f32) -> Color {
let h_prime = (hue * 6.) % 6.;
let i = h_prime as i32;
@@ -479,7 +551,7 @@ impl Color {
4 => (t, p, value),
_ => (value, p, q),
};
Color { red, green, blue, alpha }
Color::from_gamma_srgb_channels(red, green, blue, alpha)
}
/// Return the `red` component.
@@ -534,48 +606,56 @@ impl Color {
self.alpha
}
/// Whether the alpha channel is at (or within an epsilon of) fully opaque.
#[inline(always)]
pub fn is_opaque(&self) -> bool {
self.alpha > 1. - f32::EPSILON
}
/// Mean of the three RGB channels.
#[inline(always)]
pub fn average_rgb_channels(&self) -> f32 {
(self.red + self.green + self.blue) / 3.
}
/// Minimum of the three RGB channels.
#[inline(always)]
pub fn minimum_rgb_channels(&self) -> f32 {
self.red.min(self.green).min(self.blue)
}
/// Maximum of the three RGB channels.
#[inline(always)]
pub fn maximum_rgb_channels(&self) -> f32 {
self.red.max(self.green).max(self.blue)
}
/// Relative luminance using Rec.709 / sRGB-primary weights, computed on linear-light RGB.
// From https://stackoverflow.com/a/56678483/775283
#[inline(always)]
pub fn luminance_srgb(&self) -> f32 {
pub fn luminance_rec_709(&self) -> f32 {
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
}
/// Luma using Rec.601 SDTV coefficients.
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
#[inline(always)]
pub fn luminance_rec_601(&self) -> f32 {
0.299 * self.red + 0.587 * self.green + 0.114 * self.blue
}
/// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing.
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
#[inline(always)]
pub fn luminance_rec_601_rounded(&self) -> f32 {
0.3 * self.red + 0.59 * self.green + 0.11 * self.blue
}
/// Perceptual lightness (CIE L*) of the Rec.709 luminance, normalized to 0..1.
// From https://stackoverflow.com/a/56678483/775283
#[inline(always)]
pub fn luminance_perceptual(&self) -> f32 {
let luminance = self.luminance_srgb();
let luminance = self.luminance_rec_709();
if luminance <= 0.008856 {
(luminance * 903.3) / 100.
@@ -584,6 +664,7 @@ impl Color {
}
}
/// Construct an opaque grayscale color where R = G = B = `luminance`.
#[inline(always)]
pub fn from_luminance(luminance: f32) -> Color {
Color {
@@ -594,26 +675,30 @@ impl Color {
}
}
/// Shift all RGB channels by the offset that moves Rec.601-rounded luma to `luminance`, clamping channels to 0..1. Approximate; channels above 1 are lost.
#[inline(always)]
pub fn with_luminance(&self, luminance: f32) -> Color {
let delta = luminance - self.luminance_rec_601_rounded();
self.map_rgb(|c| (c + delta).clamp(0., 1.))
}
/// The RGB chroma range, `max - min` across the three channels. Not the HSL/HSV saturation (use [`Self::to_hsla`] or [`Self::to_hsva`] for those).
#[inline(always)]
pub fn saturation(&self) -> f32 {
pub fn chroma_range(&self) -> f32 {
let max = (self.red).max(self.green).max(self.blue);
let min = (self.red).min(self.green).min(self.blue);
max - min
}
/// Replace HSL saturation with the given value, preserving hue, lightness, and alpha.
#[inline(always)]
pub fn with_saturation(&self, saturation: f32) -> Color {
let [hue, _, lightness, alpha] = self.to_hsla();
Color::from_hsla(hue, saturation, lightness, alpha)
}
/// Replace the alpha channel, leaving RGB unchanged.
pub fn with_alpha(&self, alpha: f32) -> Color {
Color {
red: self.red,
@@ -623,6 +708,7 @@ impl Color {
}
}
/// Replace the red channel, leaving the others unchanged.
pub fn with_red(&self, red: f32) -> Color {
Color {
red,
@@ -632,6 +718,7 @@ impl Color {
}
}
/// Replace the green channel, leaving the others unchanged.
pub fn with_green(&self, green: f32) -> Color {
Color {
red: self.red,
@@ -641,6 +728,7 @@ impl Color {
}
}
/// Replace the blue channel, leaving the others unchanged.
pub fn with_blue(&self, blue: f32) -> Color {
Color {
red: self.red,
@@ -650,21 +738,25 @@ impl Color {
}
}
/// Per-channel "Normal" blend: returns the source channel unchanged.
#[inline(always)]
pub fn blend_normal(_c_b: f32, c_s: f32) -> f32 {
c_s
}
/// Per-channel "Multiply" blend.
#[inline(always)]
pub fn blend_multiply(c_b: f32, c_s: f32) -> f32 {
c_s * c_b
}
/// Per-channel "Darken" blend: the smaller of the two.
#[inline(always)]
pub fn blend_darken(c_b: f32, c_s: f32) -> f32 {
c_s.min(c_b)
}
/// Per-channel "Color Burn" blend.
#[inline(always)]
pub fn blend_color_burn(c_b: f32, c_s: f32) -> f32 {
if c_b == 1. {
@@ -676,41 +768,49 @@ impl Color {
}
}
/// Per-channel "Linear Burn" blend.
#[inline(always)]
pub fn blend_linear_burn(c_b: f32, c_s: f32) -> f32 {
c_b + c_s - 1.
}
/// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB.
#[inline(always)]
pub fn blend_darker_color(&self, other: Color) -> Color {
if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other }
}
/// Per-channel "Screen" blend.
#[inline(always)]
pub fn blend_screen(c_b: f32, c_s: f32) -> f32 {
1. - (1. - c_s) * (1. - c_b)
}
/// Per-channel "Lighten" blend: the larger of the two.
#[inline(always)]
pub fn blend_lighten(c_b: f32, c_s: f32) -> f32 {
c_s.max(c_b)
}
/// Per-channel "Color Dodge" blend.
#[inline(always)]
pub fn blend_color_dodge(c_b: f32, c_s: f32) -> f32 {
if c_s == 1. { 1. } else { (c_b / (1. - c_s)).min(1.) }
}
/// Per-channel "Linear Dodge" (Add) blend.
#[inline(always)]
pub fn blend_linear_dodge(c_b: f32, c_s: f32) -> f32 {
c_b + c_s
}
/// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB.
#[inline(always)]
pub fn blend_lighter_color(&self, other: Color) -> Color {
if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other }
}
/// Per-channel "Soft Light" blend.
pub fn blend_softlight(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
c_b - (1. - 2. * c_s) * c_b * (1. - c_b)
@@ -720,6 +820,7 @@ impl Color {
}
}
/// Per-channel "Hard Light" blend.
pub fn blend_hardlight(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
Color::blend_multiply(2. * c_s, c_b)
@@ -728,6 +829,7 @@ impl Color {
}
}
/// Per-channel "Vivid Light" blend.
pub fn blend_vivid_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
Color::blend_color_burn(2. * c_s, c_b)
@@ -736,6 +838,7 @@ impl Color {
}
}
/// Per-channel "Linear Light" blend.
pub fn blend_linear_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
Color::blend_linear_burn(2. * c_s, c_b)
@@ -744,6 +847,7 @@ impl Color {
}
}
/// Per-channel "Pin Light" blend.
pub fn blend_pin_light(c_b: f32, c_s: f32) -> f32 {
if c_s <= 0.5 {
Color::blend_darken(2. * c_s, c_b)
@@ -752,45 +856,54 @@ impl Color {
}
}
/// Per-channel "Hard Mix" blend: thresholds Linear Light at 0.5.
pub fn blend_hard_mix(c_b: f32, c_s: f32) -> f32 {
if Color::blend_linear_light(c_b, c_s) < 0.5 { 0. } else { 1. }
}
/// Per-channel "Difference" blend.
pub fn blend_difference(c_b: f32, c_s: f32) -> f32 {
(c_b - c_s).abs()
}
/// Per-channel "Exclusion" blend.
pub fn blend_exclusion(c_b: f32, c_s: f32) -> f32 {
c_b + c_s - 2. * c_b * c_s
}
/// Per-channel "Subtract" blend.
pub fn blend_subtract(c_b: f32, c_s: f32) -> f32 {
c_b - c_s
}
/// Per-channel "Divide" blend.
pub fn blend_divide(c_b: f32, c_s: f32) -> f32 {
if c_b == 0. { 1. } else { c_b / c_s }
}
/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma.
pub fn blend_hue(&self, c_s: Color) -> Color {
let sat_b = self.saturation();
let sat_b = self.chroma_range();
let lum_b = self.luminance_rec_601();
c_s.with_saturation(sat_b).with_luminance(lum_b)
}
/// Whole-color "Saturation" blend: this color's hue/luma with source saturation.
pub fn blend_saturation(&self, c_s: Color) -> Color {
let sat_s = c_s.saturation();
let sat_s = c_s.chroma_range();
let lum_b = self.luminance_rec_601();
self.with_saturation(sat_s).with_luminance(lum_b)
}
/// Whole-color "Color" blend: source hue/saturation with this color's luma.
pub fn blend_color(&self, c_s: Color) -> Color {
let lum_b = self.luminance_rec_601();
c_s.with_luminance(lum_b)
}
/// Whole-color "Luminosity" blend: this color's hue/saturation with source luma.
pub fn blend_luminosity(&self, c_s: Color) -> Color {
let lum_s = c_s.luminance_rec_601();
@@ -810,98 +923,43 @@ impl Color {
(self.red, self.green, self.blue, self.alpha)
}
/// Return an 8-character RGBA hex string (without a # prefix). Use this if the [`Color`] is in linear space.
///
/// # Examples
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha
/// assert_eq!("3240a261", color.to_rgba_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
/// ```
#[cfg(feature = "std")]
pub fn to_rgba_hex_srgb(&self) -> String {
let gamma = self.to_gamma_srgb();
format!(
"{:02x?}{:02x?}{:02x?}{:02x?}",
(gamma.r() * 255.) as u8,
(gamma.g() * 255.) as u8,
(gamma.b() * 255.) as u8,
(gamma.a() * 255.) as u8,
)
}
/// Convert this color to HSV coordinates (all between 0 and 1).
/// HSV is defined on sRGB display values, so this color's linear RGB is gamma-encoded before the HSV math.
pub fn to_hsva(&self) -> [f32; 4] {
#[cfg(feature = "std")]
let rem = |x: f32, m: f32| x.rem_euclid(m);
#[cfg(not(feature = "std"))]
let rem = |x: f32, m: f32| x.rem_euclid(&m);
/// Return a 6-character RGB hex string (without a # prefix). Use this if the [`Color`] is in linear space.
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha
/// assert_eq!("3240a2", color.to_rgb_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
/// ```
#[cfg(feature = "std")]
pub fn to_rgb_hex_srgb(&self) -> String {
self.to_gamma_srgb().to_rgb_hex_srgb_from_gamma()
}
let [red, green, blue, alpha] = self.to_gamma_srgb_channels();
let max = red.max(green).max(blue);
let min = red.min(green).min(blue);
let delta = max - min;
/// Return a 6-character RGB hex string (without a # prefix). Use this if the [`Color`] is in gamma space.
/// ```
/// use core_types::color::Color;
/// let color = Color::from_rgba8_srgb(0x52, 0x67, 0xFA, 0x61); // Premultiplied alpha
/// assert_eq!("3240a2", color.to_rgb_hex_srgb()); // Equivalent hex incorporating premultiplied alpha
/// ```
#[cfg(feature = "std")]
pub fn to_rgb_hex_srgb_from_gamma(&self) -> String {
format!("{:02x?}{:02x?}{:02x?}", (self.r() * 255.) as u8, (self.g() * 255.) as u8, (self.b() * 255.) as u8)
}
let mut hue = if delta == 0. {
0.
} else if max == red {
rem((green - blue) / delta, 6.)
} else if max == green {
(blue - red) / delta + 2.
} else {
(red - green) / delta + 4.
};
hue = rem(hue * 60. + 360., 360.) / 360.;
/// Return an 8-character RGBA hex string (without a # prefix). Use this if the [`Color`] is in gamma space.
#[cfg(feature = "std")]
pub fn to_rgba_hex_srgb_from_gamma(&self) -> String {
format!(
"{:02x?}{:02x?}{:02x?}{:02x?}",
(self.r() * 255.) as u8,
(self.g() * 255.) as u8,
(self.b() * 255.) as u8,
(self.a() * 255.) as u8,
)
}
let saturation = if max == 0. { 0. } else { delta / max };
let value = max;
/// [`Color::BLACK`] or [`Color::WHITE`], whichever gives more legible text against this color (alpha composited over white, WCAG-style luminance threshold). Use this if this [`Color`] is in gamma space.
pub fn contrasting_text_color_from_gamma(&self) -> Color {
let composited = Self::WHITE.alpha_blend(Self::from_unassociated_alpha(self.r(), self.g(), self.b(), self.a()));
let luminance = composited.to_linear_srgb().luminance_srgb();
// WCAG-derived perceptual midpoint between black and white (~0.179)
let threshold = (1.05_f32 * 0.05).sqrt() - 0.05;
if luminance > threshold { Self::BLACK } else { Self::WHITE }
}
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha. Use this if the [`Color`] is in gamma space.
#[inline(always)]
pub fn to_rgba8(&self) -> [u8; 4] {
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8, (self.alpha * 255.) as u8]
}
/// Return the all components as a u8 slice, first component is red, followed by green, followed by blue, followed by alpha. Use this if the [`Color`] is in linear space.
#[inline(always)]
pub fn to_rgba8_srgb(&self) -> [u8; 4] {
self.to_gamma_srgb().to_rgba8()
}
/// Return the all RGB components as a u8 slice, first component is red, followed by green, followed by blue. Use this if the [`Color`] is in gamma space.
#[inline(always)]
pub fn to_rgb8(&self) -> [u8; 3] {
[(self.red * 255.) as u8, (self.green * 255.) as u8, (self.blue * 255.) as u8]
}
/// Return the all RGB components as a u8 slice, first component is red, followed by green, followed by blue. Use this if the [`Color`] is in linear space.
#[inline(always)]
pub fn to_rgb8_srgb(&self) -> [u8; 3] {
self.to_gamma_srgb().to_rgb8()
[hue, saturation, value, alpha]
}
// https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
/// Convert a [Color] to a hue, saturation, lightness and alpha (all between 0 and 1)
/// Convert this color to HSL coordinates (all between 0 and 1).
/// HSL is defined on sRGB display values, so this color's linear RGB is gamma-encoded before the HSL math.
pub fn to_hsla(&self) -> [f32; 4] {
let min_channel = self.red.min(self.green).min(self.blue);
let max_channel = self.red.max(self.green).max(self.blue);
let [red, green, blue, alpha] = self.to_gamma_srgb_channels();
let min_channel = red.min(green).min(blue);
let max_channel = red.max(green).max(blue);
let lightness = (min_channel + max_channel) / 2.;
let saturation = if min_channel == max_channel {
@@ -911,39 +969,22 @@ impl Color {
} else {
(max_channel - min_channel) / (2. - max_channel - min_channel)
};
let hue = if self.red >= self.green && self.red >= self.blue {
(self.green - self.blue) / (max_channel - min_channel)
} else if self.green >= self.red && self.green >= self.blue {
2. + (self.blue - self.red) / (max_channel - min_channel)
let hue = if red >= green && red >= blue {
(green - blue) / (max_channel - min_channel)
} else if green >= red && green >= blue {
2. + (blue - red) / (max_channel - min_channel)
} else {
4. + (self.red - self.green) / (max_channel - min_channel)
4. + (red - green) / (max_channel - min_channel)
} / 6.;
#[cfg(feature = "std")]
let hue = hue.rem_euclid(1.);
#[cfg(not(feature = "std"))]
let hue = hue.rem_euclid(&1.);
[hue, saturation, lightness, self.alpha]
[hue, saturation, lightness, alpha]
}
/// Creates a color from a hex color code string with an optional `#` prefix, such as `#RRGGBB`, `RRGGBB`, `#RRGGBBAA`, or `RRGGBBAA`.
/// Returns `None` for invalid or unrecognized strings.
#[cfg(feature = "std")]
pub fn from_hex_str(hex: &str) -> Option<Color> {
let hex = hex.trim().trim_start_matches('#');
if hex.len() != 6 && hex.len() != 8 {
return None;
}
let red = u8::from_str_radix(&hex[0..2], 16).ok()? as f32 / 255.;
let green = u8::from_str_radix(&hex[2..4], 16).ok()? as f32 / 255.;
let blue = u8::from_str_radix(&hex[4..6], 16).ok()? as f32 / 255.;
let alpha = if hex.len() == 8 { u8::from_str_radix(&hex[6..8], 16).ok()? as f32 / 255. } else { 1. };
Some(Color { red, green, blue, alpha })
}
/// Linearly interpolates between two colors based on t.
///
/// T must be between 0 and 1.
/// Linearly interpolate each RGBA channel between `self` (`t = 0`) and `other` (`t = 1`); `t` must be in 0..=1.
#[inline(always)]
pub fn lerp(&self, other: &Color, t: f32) -> Self {
assert!((0. ..=1.).contains(&t));
@@ -955,70 +996,62 @@ impl Color {
)
}
/// Generic power curve `c.powf(1 / exponent)` applied per RGB channel. Distinct from the sRGB transfer curve (see [`Self::to_gamma_srgb_channels`]).
/// The expected output must still be treated as linear-light.
#[inline(always)]
pub fn gamma(&self, gamma: f32) -> Color {
let gamma = gamma.max(0.0001);
pub fn apply_gamma_exponent(&self, exponent: f32) -> Color {
let exponent = exponent.max(0.0001);
// From https://www.dfstudios.co.uk/articles/programming/image-programming-algorithms/image-processing-algorithms-part-6-gamma-correction/
let inverse_gamma = 1. / gamma;
self.map_rgb(|c: f32| c.powf(inverse_gamma))
let inverse = 1. / exponent;
self.map_rgb(|c: f32| c.powf(inverse))
}
/// Decompose into the four channel components after sRGB gamma encoding (linear → gamma). Alpha is unchanged.
/// Use [`Self::from_gamma_srgb_channels`] to wrap these gamma-encoded channels back into a linear-light `Color`.
#[inline(always)]
pub fn to_linear_srgb(&self) -> Self {
Self {
red: Self::srgb_to_linear(self.red),
green: Self::srgb_to_linear(self.green),
blue: Self::srgb_to_linear(self.blue),
alpha: self.alpha,
pub fn to_gamma_srgb_channels(&self) -> [f32; 4] {
[super::linear_to_srgb(self.red), super::linear_to_srgb(self.green), super::linear_to_srgb(self.blue), self.alpha]
}
/// Construct a `Color` from sRGB gamma-encoded channel components, decoding RGB to linear-light. Alpha is unchanged.
#[inline(always)]
pub fn from_gamma_srgb_channels(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color {
red: super::srgb_to_linear(red),
green: super::srgb_to_linear(green),
blue: super::srgb_to_linear(blue),
alpha,
}
}
/// Apply `f` to each RGB channel after sRGB gamma encoding, returning a linear-light `Color`. Alpha is unchanged.
/// Equivalent to unpacking via [`Self::to_gamma_srgb_channels`], mapping per channel, and rewrapping via [`Self::from_gamma_srgb_channels`].
#[inline(always)]
pub fn to_gamma_srgb(&self) -> Self {
Self {
red: Self::linear_to_srgb(self.red),
green: Self::linear_to_srgb(self.green),
blue: Self::linear_to_srgb(self.blue),
alpha: self.alpha,
}
}
#[inline(always)]
pub fn srgb_to_linear(channel: f32) -> f32 {
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
}
#[inline(always)]
pub fn linear_to_srgb(channel: f32) -> f32 {
if channel <= 0.0031308 { channel * 12.92 } else { 1.055 * channel.powf(1. / 2.4) - 0.055 }
pub fn map_gamma_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Color {
let [r, g, b, a] = self.to_gamma_srgb_channels();
Color::from_gamma_srgb_channels(f(r), f(g), f(b), a)
}
/// Apply `f` to each of the four RGBA channels independently.
#[inline(always)]
pub fn map_rgba<F: Fn(f32) -> f32>(&self, f: F) -> Self {
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), f(self.a()))
}
/// Apply `f` to each of the three RGB channels; alpha is unchanged.
#[inline(always)]
pub fn map_rgb<F: Fn(f32) -> f32>(&self, f: F) -> Self {
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), self.a())
}
/// Multiply all four channels (including alpha) by `opacity`, applying an additional premultiplication factor to this Color.
#[inline(always)]
pub fn apply_opacity(&self, opacity: f32) -> Self {
Self::from_rgbaf32_unchecked(self.r() * opacity, self.g() * opacity, self.b() * opacity, self.a() * opacity)
}
#[inline(always)]
pub fn to_associated_alpha(&self, alpha: f32) -> Self {
Self {
red: self.red * alpha,
green: self.green * alpha,
blue: self.blue * alpha,
alpha: self.alpha * alpha,
}
}
/// Divide RGB by alpha to recover unassociated (straight-alpha) channels; no-op if alpha is zero.
#[inline(always)]
pub fn to_unassociated_alpha(&self) -> Self {
if self.alpha == 0. {
@@ -1033,6 +1066,7 @@ impl Color {
}
}
/// Apply a per-channel blend function to this color (unmultiplied) and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1.
#[inline(always)]
pub fn blend_rgb<F: Fn(f32, f32) -> f32>(&self, other: Color, f: F) -> Self {
let background = self.to_unassociated_alpha();
@@ -1044,6 +1078,7 @@ impl Color {
}
}
/// Porter-Duff "source over" composite of `other` over `self`. Both colors must use associated (premultiplied) alpha.
#[inline(always)]
pub fn alpha_blend(&self, other: Color) -> Self {
let inv_alpha = 1. - other.alpha;
@@ -1055,6 +1090,7 @@ impl Color {
}
}
/// Replace alpha with `self.alpha + other.alpha`, clamped to 0..1; RGB is unchanged.
#[inline(always)]
pub fn alpha_add(&self, other: Color) -> Self {
Self {
@@ -1063,6 +1099,7 @@ impl Color {
}
}
/// Replace alpha with `self.alpha - other.alpha`, clamped to 0..1; RGB is unchanged.
#[inline(always)]
pub fn alpha_subtract(&self, other: Color) -> Self {
Self {
@@ -1071,6 +1108,7 @@ impl Color {
}
}
/// Replace alpha with `self.alpha * other.alpha`, clamped to 0..1; RGB is unchanged.
#[inline(always)]
pub fn alpha_multiply(&self, other: Color) -> Self {
Self {
@@ -1079,6 +1117,7 @@ impl Color {
}
}
/// Construct from a `glam::Vec4` where `(x, y, z, w)` map to `(red, green, blue, alpha)`.
#[inline(always)]
pub const fn from_vec4(vec: Vec4) -> Self {
Self {
@@ -1089,6 +1128,7 @@ impl Color {
}
}
/// Pack into a `glam::Vec4` as `(red, green, blue, alpha)`.
#[inline(always)]
pub fn to_vec4(&self) -> Vec4 {
Vec4::new(self.red, self.green, self.blue, self.alpha)
@@ -1123,7 +1163,7 @@ mod tests {
(82, 84, 84),
(255, 255, 178),
] {
let col = Color::from_rgb8_srgb(red, green, blue);
let col: Color = SRGBA8::new(red, green, blue, 255).into();
let [hue, saturation, lightness, alpha] = col.to_hsla();
let result = Color::from_hsla(hue, saturation, lightness, alpha);
assert!((col.r() - result.r()) < f32::EPSILON * 100.);

View File

@@ -1,7 +1,9 @@
mod color_traits;
mod color_types;
mod discrete_srgb;
mod transfer;
pub use color_traits::*;
pub use color_types::*;
pub use discrete_srgb::*;
pub use transfer::*;

View File

@@ -0,0 +1,19 @@
//! Analytic per-channel sRGB transfer functions (gamma encoding/decoding).
//!
//! These work in `f32` at full precision. For round-trip-exact `u8` ⇄ `f32` conversion at the
//! display byte boundary, use the lookup tables in [`super::discrete_srgb`] instead.
#[cfg(not(feature = "std"))]
use num_traits::float::Float;
/// Decode an sRGB gamma-encoded channel value to linear-light.
#[inline(always)]
pub fn srgb_to_linear(channel: f32) -> f32 {
if channel <= 0.04045 { channel / 12.92 } else { ((channel + 0.055) / 1.055).powf(2.4) }
}
/// Encode a linear-light channel value to sRGB gamma-encoded.
#[inline(always)]
pub fn linear_to_srgb(channel: f32) -> f32 {
if channel <= 0.0031308 { channel * 12.92 } else { 1.055 * channel.powf(1. / 2.4) - 0.055 }
}

View File

@@ -144,7 +144,14 @@ impl<P: Pixel> Image<P> {
impl Image<Color> {
/// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array)
pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self {
let data = image_data.chunks_exact(4).map(|v| Color::from_rgba8_srgb(v[0], v[1], v[2], v[3])).collect();
let data = image_data
.chunks_exact(4)
.map(|v| {
// `Image<Color>` pixels are stored linear-light with premultiplied alpha
let srgba = SRGBA8::new(v[0], v[1], v[2], v[3]);
Color::from(srgba).apply_opacity(v[3] as f32 / 255.)
})
.collect();
Image {
width,
height,
@@ -263,30 +270,6 @@ impl<P: Pixel> AsRef<Image<P>> for Image<P> {
}
}
impl From<Image<Color>> for Image<SRGBA8> {
fn from(image: Image<Color>) -> Self {
let data = image.data.into_iter().map(|x| x.into()).collect();
Self {
data,
width: image.width,
height: image.height,
base64_string: None,
}
}
}
impl From<Image<SRGBA8>> for Image<Color> {
fn from(image: Image<SRGBA8>) -> Self {
let data = image.data.into_iter().map(|x| x.into()).collect();
Self {
data,
width: image.width,
height: image.height,
base64_string: None,
}
}
}
#[cfg(test)]
mod test {
#[test]

View File

@@ -1,4 +1,5 @@
use crate::renderer::{RenderParams, format_transform_matrix};
use core_types::color::SRGBA8;
use core_types::uuid::generate_uuid;
use glam::DAffine2;
use graphic_types::vector_types::gradient::{Gradient, GradientType};
@@ -22,7 +23,7 @@ impl RenderExt for Gradient {
if position != 0. {
let _ = write!(stop, r#" offset="{}""#, (position * 1_000_000.).round() / 1_000_000.);
}
let _ = write!(stop, r##" stop-color="#{}""##, color.to_rgb_hex_srgb_from_gamma());
let _ = write!(stop, r##" stop-color="#{}""##, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(stop, r#" stop-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}
@@ -86,7 +87,7 @@ impl RenderExt for Fill {
match self {
Self::None => r#" fill="none""#.to_string(),
Self::Solid(color) => {
let mut result = format!(r##" fill="#{}""##, color.to_rgb_hex_srgb_from_gamma());
let mut result = format!(r##" fill="#{}""##, SRGBA8::from(*color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(result, r#" fill-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}
@@ -132,7 +133,7 @@ impl RenderExt for Stroke {
let paint_order = (self.paint_order != PaintOrder::StrokeAbove || render_params.override_paint_order).then_some(PaintOrder::StrokeBelow);
// Render the needed stroke attributes
let mut attributes = format!(r##" stroke="#{}""##, color.to_rgb_hex_srgb_from_gamma());
let mut attributes = format!(r##" stroke="#{}""##, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(&mut attributes, r#" stroke-opacity="{}""#, (color.a() * 1000.).round() / 1000.);
}

View File

@@ -1,10 +1,11 @@
use crate::render_ext::RenderExt;
use crate::to_peniko::BlendModeExt;
use crate::to_peniko::{BlendModeExt, ToPenikoColor};
use core_types::CacheHash;
use core_types::blending::BlendMode;
use core_types::bounds::BoundingBox;
use core_types::bounds::RenderBoundingBox;
use core_types::color::Color;
use core_types::color::SRGBA8;
use core_types::list::{Item, List};
use core_types::math::quad::Quad;
use core_types::render_complexity::RenderComplexity;
@@ -273,19 +274,20 @@ pub fn black_or_white_for_best_contrast(background: Option<Color>) -> Color {
let alpha = bg.a();
// Un-premultiply, then convert to gamma sRGB
let srgb = if alpha > f32::EPSILON {
Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb()
// Un-premultiply, then encode to gamma sRGB to do the composite in display space.
let (gamma_r, gamma_g, gamma_b) = if alpha > f32::EPSILON {
let [r, g, b, _] = Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb_channels();
(r, g, b)
} else {
Color::TRANSPARENT
(0., 0., 0.)
};
// Composite over black in sRGB space, then convert back to linear for luminance
let composited = Color::from_rgbaf32_unchecked(srgb.r() * alpha, srgb.g() * alpha, srgb.b() * alpha, 1.).to_linear_srgb();
// Composite over black in sRGB space (premultiplied by alpha), then decode to linear for the luminance test.
let composited = Color::from_gamma_srgb_channels(gamma_r * alpha, gamma_g * alpha, gamma_b * alpha, 1.);
let threshold = (1.05 * 0.05f32).sqrt() - 0.05;
if composited.luminance_srgb() > threshold { Color::BLACK } else { Color::WHITE }
if composited.luminance_rec_709() > threshold { Color::BLACK } else { Color::WHITE }
}
pub fn to_transform(transform: DAffine2) -> usvg::Transform {
@@ -311,7 +313,7 @@ fn get_outline_styles(render_params: &RenderParams) -> (kurbo::Stroke, peniko::C
};
let outline_color = black_or_white_for_best_contrast(render_params.artboard_background);
let outline_color_peniko = peniko::Color::new([outline_color.r(), outline_color.g(), outline_color.b(), outline_color.a()]);
let outline_color_peniko = SRGBA8::from(outline_color).to_peniko_color();
(outline_stroke, outline_color_peniko)
}
@@ -573,7 +575,7 @@ impl Render for List<Artboard> {
// Background
render.leaf_tag("rect", |attributes| {
attributes.push("fill", format!("#{}", background.to_rgb_hex_srgb_from_gamma()));
attributes.push("fill", format!("#{}", SRGBA8::from(background).to_rgb_hex()));
if background.a() < 1. {
attributes.push("fill-opacity", ((background.a() * 1000.).round() / 1000.).to_string());
}
@@ -629,7 +631,7 @@ impl Render for List<Artboard> {
let artboard_transform = kurbo::Affine::new(transform.to_cols_array());
let color = peniko::Color::new([background.r(), background.g(), background.b(), background.a()]);
let color = SRGBA8::from(background).to_peniko_color();
scene.push_layer(peniko::Fill::NonZero, peniko::Mix::Normal, 1., artboard_transform, &rect);
scene.fill(peniko::Fill::NonZero, artboard_transform, color, None, &rect);
scene.pop_layer();
@@ -1182,7 +1184,7 @@ impl Render for List<Vector> {
// Closures to avoid duplicated fill/stroke drawing logic
let do_fill_path = |scene: &mut Scene, path: &kurbo::BezPath, fill_rule: peniko::Fill| match element.style.fill() {
Fill::Solid(color) => {
let fill = peniko::Brush::Solid(peniko::Color::new([color.r(), color.g(), color.b(), color.a()]));
let fill = peniko::Brush::Solid(SRGBA8::from(*color).to_peniko_color());
scene.fill(fill_rule, kurbo::Affine::new(element_transform.to_cols_array()), &fill, None, path);
}
Fill::Gradient(gradient) => {
@@ -1190,7 +1192,7 @@ impl Render for List<Vector> {
for (position, color, _) in gradient.stops.interpolated_samples() {
stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
});
}
@@ -1267,7 +1269,7 @@ impl Render for List<Vector> {
let do_stroke = |scene: &mut Scene, width_scale: f64| {
if let Some(stroke) = element.style.stroke() {
let color = match stroke.color {
Some(color) => peniko::Color::new([color.r(), color.g(), color.b(), color.a()]),
Some(color) => SRGBA8::from(color).to_peniko_color(),
None => peniko::Color::TRANSPARENT,
};
let cap = match stroke.cap {
@@ -1811,7 +1813,7 @@ impl Render for List<Color> {
const MAX: f64 = 1e7;
attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}"));
attributes.push("fill", format!("#{}", color.to_rgb_hex_srgb_from_gamma()));
attributes.push("fill", format!("#{}", SRGBA8::from(*color).to_rgb_hex()));
if color.a() < 1. {
attributes.push("fill-opacity", ((color.a() * 1000.).round() / 1000.).to_string());
}
@@ -1838,7 +1840,7 @@ impl Render for List<Color> {
let blend_mode = blend_mode_attr.to_peniko();
let opacity = (opacity_attr * if render_params.for_mask { 1. } else { opacity_fill_attr }) as f32;
let vello_color = peniko::Color::new([color.r(), color.g(), color.b(), color.a()]);
let vello_color = SRGBA8::from(*color).to_peniko_color();
let rect = kurbo::Rect::from_origin_size(kurbo::Point::ZERO, kurbo::Size::new(1., 1.));
@@ -1895,7 +1897,7 @@ impl Render for List<GradientStops> {
let mut stop_string = String::new();
for (position, color, original_midpoint) in gradient.interpolated_samples() {
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, color.to_rgb_hex_srgb_from_gamma());
let _ = write!(stop_string, r##"<stop offset="{}" stop-color="#{}""##, position, SRGBA8::from(color).to_rgb_hex());
if color.a() < 1. {
let _ = write!(stop_string, r#" stop-opacity="{}""#, color.a());
}
@@ -1977,7 +1979,7 @@ impl Render for List<GradientStops> {
for (position, color, _) in gradient.interpolated_samples() {
stops.push(peniko::ColorStop {
offset: position as f32,
color: peniko::color::DynamicColor::from_alpha_color(peniko::Color::new([color.r(), color.g(), color.b(), color.a()])),
color: peniko::color::DynamicColor::from_alpha_color(SRGBA8::from(color).to_peniko_color()),
})
}

View File

@@ -1,10 +1,22 @@
use core_types::BlendMode;
use core_types::color::SRGBA8;
use vello::peniko;
pub trait BlendModeExt {
fn to_peniko(&self) -> peniko::Mix;
}
pub trait ToPenikoColor {
fn to_peniko_color(&self) -> peniko::Color;
}
impl ToPenikoColor for SRGBA8 {
#[inline(always)]
fn to_peniko_color(&self) -> peniko::Color {
peniko::Color::from_rgba8(self.red, self.green, self.blue, self.alpha)
}
}
impl BlendModeExt for BlendMode {
fn to_peniko(&self) -> peniko::Mix {
match self {

View File

@@ -1,4 +1,6 @@
use core_types::{Color, render_complexity::RenderComplexity};
use core_types::Color;
use core_types::color::SRGBA8;
use core_types::render_complexity::RenderComplexity;
use dyn_any::DynAny;
use glam::{DAffine2, DVec2};
@@ -13,9 +15,9 @@ pub enum GradientType {
}
// TODO: Someday we could switch this to a Box[T] to avoid over-allocation
// TODO: Use linear not gamma colors
/// A list of colors associated with positions (in the range 0 to 1) along a gradient.
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
/// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient.
///
/// Not exposed via Tsify; use [`GradientStopsUI`] at the JS boundary.
#[derive(Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct GradientStops {
@@ -27,6 +29,59 @@ pub struct GradientStops {
pub color: Vec<Color>,
}
/// JS-boundary version of [`GradientStops`] where stop colors are [`SRGBA8`] byte triples instead of linear-light [`Color`].
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Debug, Clone, PartialEq, Default, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct GradientStopsUI {
pub position: Vec<f64>,
pub midpoint: Vec<f64>,
pub color: Vec<SRGBA8>,
}
impl From<&GradientStops> for GradientStopsUI {
fn from(s: &GradientStops) -> Self {
Self {
position: s.position.clone(),
midpoint: s.midpoint.clone(),
color: s.color.iter().map(|c| SRGBA8::from(*c)).collect(),
}
}
}
impl From<&GradientStopsUI> for GradientStops {
fn from(s: &GradientStopsUI) -> Self {
Self {
position: s.position.clone(),
midpoint: s.midpoint.clone(),
color: s.color.iter().map(|c| Color::from(*c)).collect(),
}
}
}
impl GradientStopsUI {
/// CSS `linear-gradient(...)` string. Stops are emitted as `#rrggbbaa` hex (already gamma-encoded bytes).
pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 {
let hex = self.color.first().map(|c| c.to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
}
// Sample via the midpoint-aware subdivision used for SVG/Vello stops so browser interpolation matches
let stops: GradientStops = self.into();
let pieces = stops
.interpolated_samples()
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
let hex = SRGBA8::from(color).to_rgba_hex();
format!("#{hex} {percent}%")
})
.collect::<Vec<_>>()
.join(", ");
format!("linear-gradient(to right, {pieces})")
}
}
// TODO: Eventually remove this migration document upgrade code
impl<'de> serde::Deserialize<'de> for GradientStops {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
@@ -294,7 +349,7 @@ impl GradientStops {
/// Build a CSS `linear-gradient(...)` string suitable for use as a `background-image`. Samples the midpoint curves so the rendered gradient matches Graphite's interpolation rather than browser defaults.
pub fn to_css_linear_gradient(&self) -> String {
if self.position.len() <= 1 {
let hex = self.color.first().map(|c| c.to_rgba_hex_srgb_from_gamma()).unwrap_or_else(|| "000000ff".to_string());
let hex = self.color.first().map(|c| SRGBA8::from(*c).to_rgba_hex()).unwrap_or_else(|| "000000ff".to_string());
return format!("linear-gradient(to right, #{hex} 0%, #{hex} 100%)");
}
let pieces = self
@@ -302,7 +357,7 @@ impl GradientStops {
.into_iter()
.map(|(position, color, _)| {
let percent = ((position * 100.) * 1e2).round() / 1e2;
format!("#{} {percent}%", color.to_rgba_hex_srgb_from_gamma())
format!("#{} {percent}%", SRGBA8::from(color).to_rgba_hex())
})
.collect::<Vec<_>>()
.join(", ");
@@ -313,13 +368,17 @@ impl GradientStops {
///
/// Each sample is `(position, color, original_midpoint)` where `original_midpoint` is `Some(f64)` with the corresponding
/// midpoint for actual gradient stops, and `None` for interpolated samples added to approximate midpoint curves.
///
/// Interpolation is performed in sRGB gamma space (then lifted back to linear-light for output) because the downstream SVG/CSS
/// renderer interpolates between adjacent `<stop>` colors in gamma space; doing the subdivision math in the same space ensures
/// the chosen samples actually match the curve the browser will draw.
pub fn interpolated_samples(&self) -> Vec<(f64, Color, Option<f64>)> {
/// Controls accuracy vs. number of samples tradeoff.
/// 2/255 means the linear approximation will deviate by no more than 2 gradations of 8-bit color from the theoretically perfect curve with this midpoint bias.
const THRESHOLD: f64 = 2. / 255.;
#[allow(clippy::too_many_arguments)]
fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a: Color, color_b: Color, result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) {
fn subdivide(left: f64, right: f64, midpoint: f64, pos_a: f64, pos_b: f64, color_a_gamma: [f32; 4], color_b_gamma: [f32; 4], result: &mut Vec<(f64, Color, Option<f64>)>, depth: u32) {
const MAX_DEPTH: u32 = 20;
if depth >= MAX_DEPTH {
return;
@@ -333,13 +392,18 @@ impl GradientStops {
let y_linear = (y_left + y_right) / 2.;
if (y_actual - y_linear).abs() > THRESHOLD {
subdivide(left, mid, midpoint, pos_a, pos_b, color_a, color_b, result, depth + 1);
subdivide(left, mid, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
let global_pos = pos_a + mid * (pos_b - pos_a);
let color = color_a.lerp(&color_b, y_actual as f32);
let t = y_actual as f32;
let r = color_a_gamma[0] + (color_b_gamma[0] - color_a_gamma[0]) * t;
let g = color_a_gamma[1] + (color_b_gamma[1] - color_a_gamma[1]) * t;
let b = color_a_gamma[2] + (color_b_gamma[2] - color_a_gamma[2]) * t;
let a = color_a_gamma[3] + (color_b_gamma[3] - color_a_gamma[3]) * t;
let color = Color::from_gamma_srgb_channels(r, g, b, a);
result.push((global_pos, color, None));
subdivide(mid, right, midpoint, pos_a, pos_b, color_a, color_b, result, depth + 1);
subdivide(mid, right, midpoint, pos_a, pos_b, color_a_gamma, color_b_gamma, result, depth + 1);
}
}
@@ -368,7 +432,7 @@ impl GradientStops {
// Only subdivide if midpoint deviates from linear (0.5)
if (midpoint - 0.5).abs() >= 1e-6 {
subdivide(0., 1., midpoint, pos_a, pos_b, color_a, color_b, &mut result, 0);
subdivide(0., 1., midpoint, pos_a, pos_b, color_a.to_gamma_srgb_channels(), color_b.to_gamma_srgb_channels(), &mut result, 0);
}
// Add the end stop
@@ -440,7 +504,7 @@ impl std::fmt::Display for Gradient {
let stops = self
.stops
.iter()
.map(|stop| format!("[{}%: #{}]", round(stop.position * 100.), stop.color.to_rgba_hex_srgb()))
.map(|stop| format!("[{}%: #{}]", round(stop.position * 100.), SRGBA8::from(stop.color).to_rgba_hex()))
.collect::<Vec<_>>()
.join(", ");
write!(f, "{} Gradient: {stops}", self.gradient_type)
@@ -454,12 +518,12 @@ impl Gradient {
GradientStop {
position: 0.,
midpoint: 0.5,
color: start_color.to_gamma_srgb(),
color: start_color,
},
GradientStop {
position: 1.,
midpoint: 0.5,
color: end_color.to_gamma_srgb(),
color: end_color,
},
]);

View File

@@ -3,7 +3,7 @@
pub use crate::gradient::*;
use core_types::ATTR_OPACITY;
use core_types::Color;
use core_types::color::Alpha;
use core_types::color::{Alpha, SRGBA8};
use core_types::list::List;
use core_types::transform::Transform;
use dyn_any::DynAny;
@@ -30,7 +30,7 @@ impl std::fmt::Display for Fill {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "None"),
Self::Solid(color) => write!(f, "#{} (Alpha: {}%)", color.to_rgb_hex_srgb(), color.a() * 100.),
Self::Solid(color) => write!(f, "#{} (Alpha: {}%)", SRGBA8::from(*color).to_rgb_hex(), color.a() * 100.),
Self::Gradient(gradient) => write!(f, "{gradient}"),
}
}
@@ -161,19 +161,75 @@ impl From<Gradient> for Fill {
/// Can be None, a solid [Color], or a linear/radial [Gradient].
///
/// In the future we'll probably also add a pattern fill.
///
/// Use [`FillChoiceUI`] at the JS boundary.
#[repr(C)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[derive(Default, Debug, Clone, PartialEq, graphene_hash::CacheHash, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillChoice {
#[default]
None,
/// WARNING: Color is gamma, not linear!
Solid(Color),
/// WARNING: Color stops are gamma, not linear!
Gradient(GradientStops),
}
// TODO: Deprecate [`FillChoice`] and keep this, renamed, as the main widget-controlling type
/// JS-boundary version of [`FillChoice`] where the solid color is [`SRGBA8`] and the gradient is [`GradientStopsUI`].
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(from_wasm_abi))]
#[derive(Default, Debug, Clone, PartialEq, DynAny)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum FillChoiceUI {
#[default]
None,
Solid(SRGBA8),
Gradient(GradientStopsUI),
}
impl From<&FillChoice> for FillChoiceUI {
fn from(value: &FillChoice) -> Self {
match value {
FillChoice::None => Self::None,
FillChoice::Solid(color) => Self::Solid(SRGBA8::from(*color)),
FillChoice::Gradient(stops) => Self::Gradient(GradientStopsUI::from(stops)),
}
}
}
impl From<&FillChoiceUI> for FillChoice {
fn from(value: &FillChoiceUI) -> Self {
match value {
FillChoiceUI::None => Self::None,
FillChoiceUI::Solid(srgba) => Self::Solid(Color::from(*srgba)),
FillChoiceUI::Gradient(stops) => Self::Gradient(GradientStops::from(stops)),
}
}
}
impl FillChoiceUI {
pub fn as_solid(&self) -> Option<SRGBA8> {
let Self::Solid(c) = self else { return None };
Some(*c)
}
pub fn as_gradient(&self) -> Option<&GradientStopsUI> {
let Self::Gradient(g) = self else { return None };
Some(g)
}
/// Build a CSS `background-image` string (always a `linear-gradient(...)`) representing this fill, or `None` if the fill is [`FillChoiceUI::None`].
/// Solid colors become a degenerate gradient between the same color so the CSS variable can always be assigned to a `background-image`.
pub fn to_css_background_image(&self) -> Option<String> {
match self {
Self::None => None,
Self::Solid(srgba) => {
let hex = srgba.to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
}
}
}
impl FillChoice {
pub fn as_solid(&self) -> Option<Color> {
let Self::Solid(color) = self else { return None };
@@ -190,7 +246,7 @@ impl FillChoice {
match self {
Self::None => None,
Self::Solid(color) => {
let hex = color.to_rgba_hex_srgb_from_gamma();
let hex = SRGBA8::from(*color).to_rgba_hex();
Some(format!("linear-gradient(#{hex}, #{hex})"))
}
Self::Gradient(stops) => Some(stops.to_css_linear_gradient()),
@@ -526,7 +582,7 @@ impl Default for Stroke {
fn default() -> Self {
Self {
weight: 0.,
color: Some(Color::from_rgba8_srgb(0, 0, 0, 255)),
color: Some(Color::BLACK),
dash_lengths: Vec::new(),
dash_offset: 0.,
cap: StrokeCap::Butt,
@@ -553,7 +609,7 @@ impl std::fmt::Display for PathStyle {
let fill = &self.fill;
let stroke = match &self.stroke {
Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| c.to_rgba_hex_srgb()), stroke.weight),
Some(stroke) => format!("#{} (Weight: {} px)", stroke.color.map_or("None".to_string(), |c| SRGBA8::from(c).to_rgba_hex()), stroke.weight),
None => "None".to_string(),
};

View File

@@ -13,6 +13,7 @@ use crate::shader_runtime::ShaderRuntime;
use crate::texture_cache::TextureCache;
use anyhow::Result;
use core_types::Color;
use core_types::color::SRGBA8;
use futures::lock::Mutex;
use glam::{Affine2, UVec2};
use graphene_application_io::{ApplicationIo, EditorApi};
@@ -55,9 +56,9 @@ impl WgpuExecutor {
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
let [r, g, b, a] = background.unwrap_or(Color::TRANSPARENT).to_rgba8();
let SRGBA8 { red, green, blue, alpha } = background.unwrap_or(Color::TRANSPARENT).into();
let render_params = RenderParams {
base_color: vello::peniko::Color::from_rgba8(r, g, b, a),
base_color: vello::peniko::Color::from_rgba8(red, green, blue, alpha),
width: size.x,
height: size.y,
antialiasing_method: AaConfig::Msaa16,

View File

@@ -120,7 +120,9 @@ impl RasterGpuToRasterCpuConverter {
let start = row * row_stride;
let row_slice = &view[start..start + row_bytes];
for px in row_slice.chunks_exact(4) {
cpu_data.push(Color::from_rgba8_srgb(px[0], px[1], px[2], px[3]));
// `Image<Color>` pixels are stored linear-light with associated (premultiplied) alpha
let srgba = SRGBA8::new(px[0], px[1], px[2], px[3]);
cpu_data.push(Color::from(srgba).apply_opacity(px[3] as f32 / 255.));
}
}

View File

@@ -2,6 +2,7 @@
use base64::Engine;
#[cfg(target_family = "wasm")]
use canvas_utils::{Canvas, CanvasHandle};
use core_types::color::SRGBA8;
use core_types::list::{Item, List};
#[cfg(target_family = "wasm")]
use core_types::math::bbox::Bbox;
@@ -123,7 +124,15 @@ fn string_to_bytes(_: impl Ctx, string: String) -> List<u8> {
#[node_macro::node(category("Web Request"), name("Image to Bytes"))]
fn image_to_bytes(_: impl Ctx, image: List<Raster<CPU>>) -> List<u8> {
let Some(image) = image.element(0) else { return List::new() };
image.data.iter().flat_map(|color| color.to_rgba8_srgb()).map(Item::new_from_element).collect()
image
.data
.iter()
.flat_map(|color| {
let SRGBA8 { red, green, blue, alpha } = (*color).into();
[red, green, blue, alpha]
})
.map(Item::new_from_element)
.collect()
}
/// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue.
@@ -154,7 +163,11 @@ fn decode_image(_: impl Ctx, data: Arc<[u8]>) -> List<Raster<CPU>> {
let image = Image {
data: image
.chunks(4)
.map(|pixel| Color::from_unassociated_alpha(pixel[0], pixel[1], pixel[2], pixel[3]).to_linear_srgb())
.map(|pixel| {
// Decoded bytes are unassociated gamma sRGB; premultiply in gamma then lift to linear
let a = pixel[3];
Color::from_gamma_srgb_channels(pixel[0] * a, pixel[1] * a, pixel[2] * a, a)
})
.collect(),
width: image.width(),
height: image.height(),

View File

@@ -823,7 +823,8 @@ fn rgba_to_color(_: impl Ctx, _primary: (), red: Fraction, green: Fraction, blue
let blue = (blue as f32).clamp(0., 1.);
let alpha = (alpha as f32).clamp(0., 1.);
List::new_from_element(Color::from_rgbaf32_unchecked(red, green, blue, alpha))
// RGB user inputs are interpreted as sRGB display values; lift to linear-light for the internal `Color`
List::new_from_element(Color::from_gamma_srgb_channels(red, green, blue, alpha))
}
/// Constructs a color value from hue, saturation, value, and alpha components given as numbers from 0 to 1.
@@ -848,11 +849,11 @@ fn hsla_to_color(_: impl Ctx, _primary: (), hue: Fraction, #[default(1.)] satura
List::new_from_element(Color::from_hsla(hue, saturation, lightness, alpha))
}
/// Constructs a color value from an sRGB color code string, such as `#RRGGBB` or `#RRGGBBAA`. Invalid hex code strings produce no color.
/// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). Invalid inputs produce no color.
#[node_macro::node(category("Color"), name("Hex to Color"))]
fn hex_to_color(_: impl Ctx, hex_code: String) -> List<Color> {
match Color::from_hex_str(&hex_code) {
Some(c) => List::new_from_element(c),
match core_types::misc::parse_css_color(&hex_code) {
Some(color) => List::new_from_element(color),
None => List::new(),
}
}

View File

@@ -5,8 +5,8 @@ use crate::cubic_spline::CubicSplines;
use core::fmt::Debug;
#[cfg(feature = "std")]
use core_types::list::List;
use glam::{Vec3, Vec4};
use no_std_types::color::Color;
use glam::Vec3;
use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
use no_std_types::context::Ctx;
use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32};
use node_macro::BufferStruct;
@@ -63,7 +63,7 @@ fn luminance<T: Adjust<Color>>(
) -> T {
input.adjust(|color| {
let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_srgb(),
LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
@@ -91,7 +91,7 @@ fn gamma_correction<T: Adjust<Color>>(
inverse: bool,
) -> T {
let exponent = if inverse { 1. / gamma } else { gamma };
input.adjust(|color| color.gamma(exponent));
input.adjust(|color| color.apply_gamma_exponent(exponent));
input
}
@@ -161,7 +161,7 @@ fn brightness_contrast_classic<T: Adjust<Color>>(
let offset = brightness * contrast + brightness - contrast / 2.;
input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)).to_linear_srgb());
input.adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
input
}
@@ -240,7 +240,7 @@ fn brightness_contrast<T: Adjust<Color>>(
});
let lut_max = (combined_lut.len() - 1) as f32;
input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| combined_lut[(c * lut_max).round() as usize]).to_linear_srgb());
input.adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
input
}
@@ -270,7 +270,8 @@ fn levels<T: Adjust<Color>>(
#[default(100.)] output_maximums: PercentageF32,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
// 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.;
@@ -301,15 +302,24 @@ fn levels<T: Adjust<Color>>(
// Input levels (Range: 0-1)
let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.);
let color = color.map_rgb(|c| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(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 (Range: 0-1)
let color = color.gamma(gamma);
// 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 color = color.map_rgb(|c| c * (output_maximums - output_minimums) + output_minimums);
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.to_linear_srgb()
Color::from_gamma_srgb_channels(r, g, b, a)
});
image
}
@@ -353,7 +363,8 @@ fn black_and_white<T: Adjust<Color>>(
magentas: PercentageF32,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
// 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.;
@@ -362,12 +373,11 @@ fn black_and_white<T: Adjust<Color>>(
let blues = blues / 100.;
let magentas = magentas / 100.;
let gray_base = color.r().min(color.g()).min(color.b());
let gray_base = r.min(g).min(b);
let red_part = color.r() - gray_base;
let green_part = color.g() - gray_base;
let blue_part = color.b() - gray_base;
let alpha_part = color.a();
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);
@@ -383,11 +393,15 @@ fn black_and_white<T: Adjust<Color>>(
let luminance = gray_base + additional;
// TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close)
let color = tint.with_luminance(luminance);
// 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.);
let color = Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), alpha_part);
color.to_linear_srgb()
Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part)
});
image
}
@@ -410,20 +424,17 @@ fn hue_saturation<T: Adjust<Color>>(
lightness_shift: SignedPercentageF32,
) -> T {
input.adjust(|color| {
let color = color.to_gamma_srgb();
// HSL operates on gamma-space channels
let [hue, saturation, lightness, alpha] = color.to_hsla();
let color = Color::from_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,
);
color.to_linear_srgb()
)
});
input
}
@@ -442,11 +453,9 @@ fn invert<T: Adjust<Color>>(
mut input: T,
) -> T {
input.adjust(|color| {
let color = color.to_gamma_srgb();
let color = color.map_rgb(|c| color.a() - c);
color.to_linear_srgb()
// Invert in gamma space relative to alpha
let [r, g, b, a] = color.to_gamma_srgb_channels();
Color::from_gamma_srgb_channels(a - r, a - g, a - b, a)
});
input
}
@@ -468,11 +477,11 @@ fn threshold<T: Adjust<Color>>(
luminance_calc: LuminanceCalculation,
) -> T {
image.adjust(|color| {
let min_luminance = Color::srgb_to_linear(min_luminance / 100.);
let max_luminance = Color::srgb_to_linear(max_luminance / 100.);
let min_luminance = srgb_to_linear(min_luminance / 100.);
let max_luminance = srgb_to_linear(max_luminance / 100.);
let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_srgb(),
LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
@@ -512,17 +521,22 @@ fn vibrance<T: Adjust<Color>>(
vibrance: SignedPercentageF32,
) -> T {
image.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 = color.r().max(color.g()).max(color.b());
let channel_min = color.r().min(color.g()).min(color.b());
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 == color.r() {
let green_blue_difference = (color.g() - color.b()).abs();
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 {
@@ -532,30 +546,48 @@ fn vibrance<T: Adjust<Color>>(
let channel_reduction = channel_min * scale;
let scale = 1. + scale * (1. - channel_difference);
let luminance_initial = color.to_linear_srgb().luminance_srgb();
let altered_color = color.map_rgb(|c| c * scale - channel_reduction).to_linear_srgb();
let luminance = altered_color.luminance_srgb();
let altered_color = altered_color.map_rgb(|c| c * luminance_initial / luminance);
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 channel_max = altered_color.r().max(altered_color.g()).max(altered_color.b());
let altered_color = if Color::linear_to_srgb(channel_max) > 1. {
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);
altered_color.map_rgb(|c| (c - luminance) * scale + luminance)
} else {
altered_color
};
let altered_color = altered_color.to_gamma_srgb();
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. {
altered_color
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
let luminance = color.luminance_rec_601();
// Near -0% vibrance we mostly use `altered_color`.
// Near -100% vibrance, we mostly use half the desaturated luminance color and half `altered_color`.
// 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;
altered_color.map_rgb(|c| c * (1. - factor) + luminance * factor)
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
@@ -747,16 +779,14 @@ fn channel_mixer<T: Adjust<Color>>(
_output_channel: RedGreenBlue,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
let [r, g, b, a] = color.to_gamma_srgb_channels();
let (r, g, b, a) = color.components();
let color = if monochrome {
let (out_r, out_g, out_b) = if monochrome {
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.);
let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.);
Color::from_rgbaf32_unchecked(gray, gray, gray, a)
(gray, gray, gray)
} else {
let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.);
let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.);
@@ -766,10 +796,10 @@ fn channel_mixer<T: Adjust<Color>>(
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.);
Color::from_rgbaf32_unchecked(red, green, blue, a)
(red, green, blue)
};
color.to_linear_srgb()
Color::from_gamma_srgb_channels(out_r, out_g, out_b, a)
});
image
}
@@ -873,9 +903,7 @@ fn selective_color<T: Adjust<Color>>(
_colors: SelectiveColorChoice,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
let (r, g, b, a) = color.components();
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);
@@ -945,9 +973,9 @@ fn selective_color<T: Adjust<Color>>(
}
let rgb = Vec3::new(r, g, b);
let color = Color::from_vec4(Vec4::from(((sum + rgb).clamp(Vec3::ZERO, Vec3::ONE), a)));
let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE);
color.to_linear_srgb()
Color::from_gamma_srgb_channels(out.x, out.y, out.z, a)
});
image
}
@@ -973,15 +1001,11 @@ fn posterize<T: Adjust<Color>>(
levels: u32,
) -> T {
input.adjust(|color| {
let color = color.to_gamma_srgb();
let levels = levels as f32;
// `hard_min(2)` constrains the widget but doesn't bind the data-flow input (a saved doc or upstream node could still feed 0 or 1, producing inf/NaN below).
let levels = (levels as f32).max(2.);
let number_of_areas = levels.recip();
let size_of_areas = (levels - 1.).recip();
let channel = |channel: f32| (channel / number_of_areas).floor() * size_of_areas;
let color = color.map_rgb(channel);
color.to_linear_srgb()
color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
});
input
}
@@ -1016,7 +1040,7 @@ fn exposure<T: Adjust<Color>>(
// Offset
.map_rgb(|c: f32| c + offset)
// Gamma correction
.gamma(gamma_correction);
.apply_gamma_exponent(gamma_correction);
adjusted.map_rgb(|c: f32| c.clamp(0., 1.))
});

View File

@@ -99,7 +99,7 @@ pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode,
blend_mode => apply_blend_mode(foreground, background, blend_mode),
};
background.alpha_blend(target_color.to_associated_alpha(opacity))
background.alpha_blend(target_color.apply_opacity(opacity))
}
pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color {

View File

@@ -1,4 +1,5 @@
use core_types::color::Color;
use bytemuck::{Pod, Zeroable};
use core_types::color::{Alpha, Color, Pixel, RGB};
use core_types::context::Ctx;
use core_types::list::List;
use core_types::registry::types::PixelLength;
@@ -6,6 +7,84 @@ use raster_types::Image;
use raster_types::{Bitmap, BitmapMut};
use raster_types::{CPU, Raster};
/// Working-buffer pixel for the blur algorithms' `gamma` mode: premultiplied sRGB-gamma `f32` channels.
/// Only used internally so the working buffer's color space is reflected in the type instead of stuffed into `Color` (which is linear-light by invariant).
#[repr(C)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)]
struct PremultipliedGammaPixel {
r: f32,
g: f32,
b: f32,
a: f32,
}
impl Pixel for PremultipliedGammaPixel {}
impl RGB for PremultipliedGammaPixel {
type ColorChannel = f32;
fn red(&self) -> f32 {
self.r
}
fn green(&self) -> f32 {
self.g
}
fn blue(&self) -> f32 {
self.b
}
}
impl Alpha for PremultipliedGammaPixel {
type AlphaChannel = f32;
const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. };
fn alpha(&self) -> f32 {
self.a
}
fn multiplied_alpha(&self, mult: f32) -> Self {
Self {
r: self.r * mult,
g: self.g * mult,
b: self.b * mult,
a: self.a * mult,
}
}
}
fn premultiply_gamma(buffer: Image<Color>) -> Image<PremultipliedGammaPixel> {
Image {
width: buffer.width,
height: buffer.height,
data: buffer
.data
.into_iter()
.map(|px| {
let [r, g, b, a] = px.to_gamma_srgb_channels();
PremultipliedGammaPixel { r: r * a, g: g * a, b: b * a, a }
})
.collect(),
base64_string: None,
}
}
fn unpremultiply_gamma_to_linear(buffer: Image<PremultipliedGammaPixel>) -> Image<Color> {
Image {
width: buffer.width,
height: buffer.height,
data: buffer
.data
.into_iter()
.map(|px| {
if px.a > 0. {
let inv_a = 1. / px.a;
Color::from_gamma_srgb_channels(px.r * inv_a, px.g * inv_a, px.b * inv_a, px.a)
} else {
Color::TRANSPARENT
}
})
.collect(),
base64_string: None,
}
}
/// Blurs the image with a Gaussian or box blur kernel filter.
#[node_macro::node(category("Raster: Filter"))]
async fn blur(
@@ -98,26 +177,49 @@ fn gaussian_kernel(radius: f64) -> Vec<f64> {
gaussian_kernel
}
fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
} else {
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
}
let (width, height) = original_buffer.dimensions();
// Create 1D gaussian kernel
fn gaussian_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
let kernel = gaussian_kernel(radius);
if gamma {
let working = premultiply_gamma(buffer);
let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a });
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
}
}
fn box_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
let working = premultiply_gamma(buffer);
let blurred = box_separable(working, radius, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a });
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
let mut blurred = box_separable(working, radius, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
}
}
fn gaussian_separable<P, F>(buffer: Image<P>, kernel: &[f64], construct: F) -> Image<P>
where
P: Pixel + Copy + RGB<ColorChannel = f32> + Alpha<AlphaChannel = f32>,
F: Fn(f32, f32, f32, f32) -> P,
{
let (width, height) = buffer.dimensions();
let half_kernel = kernel.len() / 2;
// Intermediate buffer for horizontal and vertical passes
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
let mut x_axis = Image::new(width, height, P::default());
let mut y_axis = Image::new(width, height, P::default());
for pass in [false, true] {
let (max, old_buffer, current_buffer) = match pass {
false => (width, &original_buffer, &mut x_axis),
false => (width, &buffer, &mut x_axis),
true => (height, &x_axis, &mut y_axis),
};
let pass = pass as usize;
@@ -140,41 +242,32 @@ fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma
}
}
// Normalize
let (r, g, b, a) = if weight_sum > 0. {
((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32)
} else {
let px = old_buffer.get_pixel(x, y).unwrap();
(px.r(), px.g(), px.b(), px.a())
};
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a));
current_buffer.set_pixel(x, y, construct(r, g, b, a));
}
}
}
if gamma {
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
} else {
y_axis.map_pixels(|px| px.to_unassociated_alpha());
}
y_axis
}
fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
} else {
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
}
let (width, height) = original_buffer.dimensions();
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
fn box_separable<P, F>(buffer: Image<P>, radius: f64, construct: F) -> Image<P>
where
P: Pixel + Copy + RGB<ColorChannel = f32> + Alpha<AlphaChannel = f32>,
F: Fn(f32, f32, f32, f32) -> P,
{
let (width, height) = buffer.dimensions();
let mut x_axis = Image::new(width, height, P::default());
let mut y_axis = Image::new(width, height, P::default());
for pass in [false, true] {
let (max, old_buffer, current_buffer) = match pass {
false => (width, &original_buffer, &mut x_axis),
false => (width, &buffer, &mut x_axis),
true => (height, &x_axis, &mut y_axis),
};
let pass = pass as usize;
@@ -196,17 +289,11 @@ fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: boo
}
let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32);
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a));
current_buffer.set_pixel(x, y, construct(r, g, b, a));
}
}
}
if gamma {
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
} else {
y_axis.map_pixels(|px| px.to_unassociated_alpha());
}
y_axis
}

View File

@@ -24,9 +24,9 @@ async fn gradient_map<T: Adjust<Color>>(
let Some(gradient) = gradient.element(0) else { return image };
image.adjust(|color| {
let intensity = color.luminance_srgb();
let intensity = color.luminance_rec_709();
let intensity = if reverse { 1. - intensity } else { intensity };
gradient.evaluate(intensity as f64).to_linear_srgb()
gradient.evaluate(intensity as f64)
});
image

View File

@@ -16,7 +16,8 @@ async fn image_color_palette(
let bins = GRID * GRID * GRID;
let mut histogram = vec![0; (bins + 1.) as usize];
let mut color_bins = vec![Vec::new(); (bins + 1.) as usize];
// Each bin stores `(red, green, blue, alpha)` tuples in sRGB gamma space; averaging in gamma space gives perceptually-uniform binning.
let mut color_bins: Vec<Vec<[f32; 4]>> = vec![Vec::new(); (bins + 1.) as usize];
for element in image.iter_element_values() {
for pixel in element.data.iter() {
@@ -27,7 +28,7 @@ async fn image_color_palette(
let bin = (r * GRID + g * GRID + b * GRID) as usize;
histogram[bin] += 1;
color_bins[bin].push(pixel.to_gamma_srgb());
color_bins[bin].push(pixel.to_gamma_srgb_channels());
}
}
@@ -39,24 +40,21 @@ async fn image_color_palette(
.flat_map(|&i| {
let list = &color_bins[i];
let mut r = 0.;
let mut g = 0.;
let mut b = 0.;
let mut a = 0.;
let [mut r, mut g, mut b, mut a] = [0.; 4];
for color in list.iter() {
r += color.r();
g += color.g();
b += color.b();
a += color.a();
for &[cr, cg, cb, ca] in list.iter() {
r += cr;
g += cg;
b += cb;
a += ca;
}
r /= list.len() as f32;
g /= list.len() as f32;
b /= list.len() as f32;
a /= list.len() as f32;
let len = list.len() as f32;
let [r, g, b, a] = [r / len, g / len, b / len, a / len];
Color::from_rgbaf32(r, g, b, a).map(Item::new_from_element).into_iter()
// Reject NaN/out-of-range averages, then lift the gamma-space bin centroid to linear-light
let in_gamut = a <= 1. && ![r, g, b, a].iter().any(|c| c.is_sign_negative() || !c.is_finite());
in_gamut.then(|| Color::from_gamma_srgb_channels(r, g, b, a)).map(Item::new_from_element).into_iter()
})
.collect()
}