mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-04 17:48:43 +00:00
8/16 bpc projects clamp and quantise layer pixels after source/masks and every effect, and the comp after every layer (16 bpc = 0..32768); 32 bpc keeps over-range values. Blend modes without HDR support clamp their inputs in 32 bpc. Working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices derived from the published primaries), footage colour profiles from stream metadata or Interpret Footage, Linearize Working Space and Blend Colors Using 1.0 Gamma. Layer cache keys include the colour settings. Project panel depth button: click cycles, Alt-click opens Project Settings. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
8.4 KiB
Rust
279 lines
8.4 KiB
Rust
//! Colour for EffectCraft: RGBA values, sRGB transfer, HSL/HSV, luminance, the label palette and
|
|
//! the 38 After-Effects-compatible blend modes.
|
|
//!
|
|
//! Blend-mode formulas come from the W3C *Compositing and Blending Level 1* specification and the
|
|
//! standard definitions of the "classic" photographic modes; they are applied to un-premultiplied
|
|
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
|
|
|
|
pub mod blend;
|
|
pub mod space;
|
|
|
|
pub use blend::{BlendMode, blend_pixel};
|
|
use serde::{Deserialize, Serialize};
|
|
pub use space::{ColorSpace, Conversion};
|
|
|
|
/// Straight (un-premultiplied) RGBA, 0..1 nominal (32 bpc may exceed).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
|
pub struct Rgba {
|
|
pub r: f32,
|
|
pub g: f32,
|
|
pub b: f32,
|
|
pub a: f32,
|
|
}
|
|
|
|
pub const fn rgba(r: f32, g: f32, b: f32, a: f32) -> Rgba {
|
|
Rgba { r, g, b, a }
|
|
}
|
|
|
|
impl Rgba {
|
|
pub const BLACK: Rgba = rgba(0.0, 0.0, 0.0, 1.0);
|
|
pub const WHITE: Rgba = rgba(1.0, 1.0, 1.0, 1.0);
|
|
pub const TRANSPARENT: Rgba = rgba(0.0, 0.0, 0.0, 0.0);
|
|
|
|
pub fn from_u8(r: u8, g: u8, b: u8) -> Rgba {
|
|
rgba(r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0)
|
|
}
|
|
/// `#rrggbb` or `#rrggbbaa`.
|
|
pub fn from_hex(s: &str) -> Option<Rgba> {
|
|
let s = s.trim().trim_start_matches('#');
|
|
let p = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
|
|
match s.len() {
|
|
6 => Some(Rgba::from_u8(p(0)?, p(2)?, p(4)?)),
|
|
8 => Some(Rgba { a: p(6)? as f32 / 255.0, ..Rgba::from_u8(p(0)?, p(2)?, p(4)?) }),
|
|
_ => None,
|
|
}
|
|
}
|
|
pub fn to_hex(&self) -> String {
|
|
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
|
|
format!("#{:02X}{:02X}{:02X}", c(self.r), c(self.g), c(self.b))
|
|
}
|
|
pub fn premultiplied(&self) -> [f32; 4] {
|
|
[self.r * self.a, self.g * self.a, self.b * self.a, self.a]
|
|
}
|
|
pub fn to_array(&self) -> [f32; 4] {
|
|
[self.r, self.g, self.b, self.a]
|
|
}
|
|
pub fn from_array(a: [f32; 4]) -> Rgba {
|
|
rgba(a[0], a[1], a[2], a[3])
|
|
}
|
|
pub fn lerp(&self, o: &Rgba, t: f32) -> Rgba {
|
|
rgba(self.r + (o.r - self.r) * t, self.g + (o.g - self.g) * t, self.b + (o.b - self.b) * t, self.a + (o.a - self.a) * t)
|
|
}
|
|
pub fn to_u8(&self) -> [u8; 4] {
|
|
let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
|
|
[c(self.r), c(self.g), c(self.b), c(self.a)]
|
|
}
|
|
}
|
|
|
|
/// sRGB electro-optical transfer: encoded → linear.
|
|
pub fn srgb_to_linear(v: f32) -> f32 {
|
|
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
|
|
}
|
|
/// Linear → sRGB encoded.
|
|
pub fn linear_to_srgb(v: f32) -> f32 {
|
|
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.max(0.0).powf(1.0 / 2.4) - 0.055 }
|
|
}
|
|
|
|
/// Rec. 709 luma weights (AE's luminance for luma mattes/keys).
|
|
pub fn luminance(r: f32, g: f32, b: f32) -> f32 {
|
|
0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
}
|
|
|
|
/// RGB (0..1) → HSL with h in 0..1.
|
|
pub fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
|
let max = r.max(g).max(b);
|
|
let min = r.min(g).min(b);
|
|
let l = (max + min) * 0.5;
|
|
if (max - min).abs() < 1e-7 {
|
|
return (0.0, 0.0, l);
|
|
}
|
|
let d = max - min;
|
|
let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
|
|
let h = if max == r {
|
|
((g - b) / d + if g < b { 6.0 } else { 0.0 }) / 6.0
|
|
} else if max == g {
|
|
((b - r) / d + 2.0) / 6.0
|
|
} else {
|
|
((r - g) / d + 4.0) / 6.0
|
|
};
|
|
(h, s, l)
|
|
}
|
|
|
|
pub fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
|
|
if s <= 0.0 {
|
|
return (l, l, l);
|
|
}
|
|
let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
|
|
let p = 2.0 * l - q;
|
|
let f = |mut t: f32| {
|
|
t = t.rem_euclid(1.0);
|
|
if t < 1.0 / 6.0 {
|
|
p + (q - p) * 6.0 * t
|
|
} else if t < 0.5 {
|
|
q
|
|
} else if t < 2.0 / 3.0 {
|
|
p + (q - p) * (2.0 / 3.0 - t) * 6.0
|
|
} else {
|
|
p
|
|
}
|
|
};
|
|
(f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0))
|
|
}
|
|
|
|
pub fn rgb_to_hsv(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
|
|
let max = r.max(g).max(b);
|
|
let min = r.min(g).min(b);
|
|
let d = max - min;
|
|
let h = if d < 1e-7 {
|
|
0.0
|
|
} else if max == r {
|
|
((g - b) / d).rem_euclid(6.0) / 6.0
|
|
} else if max == g {
|
|
((b - r) / d + 2.0) / 6.0
|
|
} else {
|
|
((r - g) / d + 4.0) / 6.0
|
|
};
|
|
let s = if max > 0.0 { d / max } else { 0.0 };
|
|
(h, s, max)
|
|
}
|
|
|
|
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
|
let h6 = h.rem_euclid(1.0) * 6.0;
|
|
let i = h6.floor();
|
|
let f = h6 - i;
|
|
let p = v * (1.0 - s);
|
|
let q = v * (1.0 - s * f);
|
|
let t = v * (1.0 - s * (1.0 - f));
|
|
match i as i32 {
|
|
0 => (v, t, p),
|
|
1 => (q, v, p),
|
|
2 => (p, v, t),
|
|
3 => (p, q, v),
|
|
4 => (t, p, v),
|
|
_ => (v, p, q),
|
|
}
|
|
}
|
|
|
|
/// Item/layer label colours (16 slots + None), defaults as in the public Labels preferences.
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
pub enum Label {
|
|
None,
|
|
#[default]
|
|
Red,
|
|
Yellow,
|
|
Aqua,
|
|
Pink,
|
|
Lavender,
|
|
Peach,
|
|
SeaFoam,
|
|
Blue,
|
|
Green,
|
|
Purple,
|
|
Orange,
|
|
Brown,
|
|
Fuchsia,
|
|
Cyan,
|
|
Sandstone,
|
|
DarkGreen,
|
|
}
|
|
|
|
impl Label {
|
|
pub const ALL: [Label; 17] = [
|
|
Label::None,
|
|
Label::Red,
|
|
Label::Yellow,
|
|
Label::Aqua,
|
|
Label::Pink,
|
|
Label::Lavender,
|
|
Label::Peach,
|
|
Label::SeaFoam,
|
|
Label::Blue,
|
|
Label::Green,
|
|
Label::Purple,
|
|
Label::Orange,
|
|
Label::Brown,
|
|
Label::Fuchsia,
|
|
Label::Cyan,
|
|
Label::Sandstone,
|
|
Label::DarkGreen,
|
|
];
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Label::None => "None",
|
|
Label::Red => "Red",
|
|
Label::Yellow => "Yellow",
|
|
Label::Aqua => "Aqua",
|
|
Label::Pink => "Pink",
|
|
Label::Lavender => "Lavender",
|
|
Label::Peach => "Peach",
|
|
Label::SeaFoam => "Sea Foam",
|
|
Label::Blue => "Blue",
|
|
Label::Green => "Green",
|
|
Label::Purple => "Purple",
|
|
Label::Orange => "Orange",
|
|
Label::Brown => "Brown",
|
|
Label::Fuchsia => "Fuchsia",
|
|
Label::Cyan => "Cyan",
|
|
Label::Sandstone => "Sandstone",
|
|
Label::DarkGreen => "Dark Green",
|
|
}
|
|
}
|
|
pub fn from_name(s: &str) -> Option<Label> {
|
|
Label::ALL.into_iter().find(|l| l.name().eq_ignore_ascii_case(s) || format!("{l:?}").eq_ignore_ascii_case(s))
|
|
}
|
|
/// sRGB 8-bit colour.
|
|
pub fn rgb(self) -> [u8; 3] {
|
|
match self {
|
|
Label::None => [0x50, 0x50, 0x50],
|
|
Label::Red => [0xb5, 0x38, 0x38],
|
|
Label::Yellow => [0xe4, 0xd8, 0x4c],
|
|
Label::Aqua => [0xa9, 0xcb, 0xc7],
|
|
Label::Pink => [0xe5, 0xbc, 0xc9],
|
|
Label::Lavender => [0xa9, 0xa9, 0xca],
|
|
Label::Peach => [0xe7, 0xc1, 0x9e],
|
|
Label::SeaFoam => [0xb3, 0xc7, 0xb3],
|
|
Label::Blue => [0x67, 0x7d, 0xe0],
|
|
Label::Green => [0x4a, 0xa4, 0x4c],
|
|
Label::Purple => [0x8e, 0x2c, 0x9a],
|
|
Label::Orange => [0xe8, 0x92, 0x0d],
|
|
Label::Brown => [0x7f, 0x45, 0x2a],
|
|
Label::Fuchsia => [0xf4, 0x6d, 0xd6],
|
|
Label::Cyan => [0x3d, 0xa2, 0xa5],
|
|
Label::Sandstone => [0xa8, 0x96, 0x77],
|
|
Label::DarkGreen => [0x1e, 0x40, 0x1e],
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn srgb_roundtrip() {
|
|
for i in 0..=100 {
|
|
let v = i as f32 / 100.0;
|
|
assert!((linear_to_srgb(srgb_to_linear(v)) - v).abs() < 1e-5);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hsl_roundtrip() {
|
|
for (r, g, b) in [(0.2, 0.4, 0.9), (1.0, 0.0, 0.0), (0.5, 0.5, 0.5), (0.9, 0.8, 0.1)] {
|
|
let (h, s, l) = rgb_to_hsl(r, g, b);
|
|
let (r2, g2, b2) = hsl_to_rgb(h, s, l);
|
|
assert!((r - r2).abs() < 1e-5 && (g - g2).abs() < 1e-5 && (b - b2).abs() < 1e-5);
|
|
let (h, s, v) = rgb_to_hsv(r, g, b);
|
|
let (r3, g3, b3) = hsv_to_rgb(h, s, v);
|
|
assert!((r - r3).abs() < 1e-5 && (g - g3).abs() < 1e-5 && (b - b3).abs() < 1e-5);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hex() {
|
|
let c = Rgba::from_hex("#2D8CEB").unwrap();
|
|
assert_eq!(c.to_hex(), "#2D8CEB");
|
|
assert!(Rgba::from_hex("zz").is_none());
|
|
}
|
|
}
|