M13.25: View > Simulate Output > My Custom RGB (primaries, white, gamma or an RGB ICC profile; kept in Settings; view.customRgb) and File menu entries for templates

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 17:46:41 -04:00
co-authored by Claude Opus 5.5
parent b760f1d23a
commit 9e70cde725
8 changed files with 621 additions and 10 deletions
+337
View File
@@ -0,0 +1,337 @@
//! A reader for matrix/TRC RGB ICC profiles (ICC.1:2001-04 v2 and ICC.1:2010 v4, the public
//! International Color Consortium specification): the red/green/blue colorants (`rXYZ`, `gXYZ`,
//! `bXYZ`), the media white point (`wtpt`), the chromatic adaptation (`chad`), the tone curves
//! (`rTRC`, `gTRC`, `bTRC` as `curv` or `para`) and the description (`desc` / `mluc`).
//!
//! Used by View ▸ Simulate Output ▸ My Custom RGB: the profile becomes primaries, a white point
//! and a curve. LUT-based (`A2B0`) and non-RGB profiles are rejected.
use crate::space::{bradford, invert, mul_vec};
/// CIE D50 (the ICC profile connection space white), xy.
pub const D50: [f64; 2] = [0.3457, 0.3585];
/// A tone curve of a profile.
#[derive(Clone, Debug, PartialEq)]
pub enum Trc {
/// `curv` with one entry, or `para` function type 0: y = x^g.
Gamma(f64),
/// `curv` table (0..=65535 samples over 0..1).
Table(Vec<u16>),
/// `para` function types 1–4: (type, parameters g a b c d e f).
Parametric(u16, [f64; 7]),
}
impl Trc {
/// Encoded value (0..1) → linear.
pub fn eval(&self, x: f64) -> f64 {
let x = x.clamp(0.0, 1.0);
match self {
Trc::Gamma(g) => x.powf(*g),
Trc::Table(t) if t.is_empty() => x,
Trc::Table(t) => {
let f = x * (t.len() - 1) as f64;
let i = (f.floor() as usize).min(t.len() - 1);
let j = (i + 1).min(t.len() - 1);
let k = f - i as f64;
(t[i] as f64 * (1.0 - k) + t[j] as f64 * k) / 65535.0
}
Trc::Parametric(ty, p) => {
let [g, a, b, c, d, e, f] = *p;
let pw = |v: f64| if v > 0.0 { v.powf(g) } else { 0.0 };
match ty {
1 => {
if x >= -b / a {
pw(a * x + b)
} else {
0.0
}
}
2 => {
if x >= -b / a {
pw(a * x + b) + c
} else {
c
}
}
3 => {
if x >= d {
pw(a * x + b)
} else {
c * x
}
}
_ => {
if x >= d {
pw(a * x + b) + e
} else {
c * x + f
}
}
}
}
}
}
/// The single gamma that best matches the curve (least squares in log space over mid-tones).
pub fn fit_gamma(&self) -> f64 {
if let Trc::Gamma(g) = self {
return *g;
}
let (mut num, mut den) = (0.0, 0.0);
for i in 4..20 {
let x = i as f64 / 20.0;
let y = self.eval(x);
if y > 1e-6 && y < 1.0 {
num += x.ln() * y.ln();
den += x.ln() * x.ln();
}
}
if den > 0.0 { (num / den).clamp(0.1, 10.0) } else { 1.0 }
}
/// Whether the curve is the sRGB (IEC 61966-2-1) piecewise curve, within 8-bit precision.
pub fn is_srgb(&self) -> bool {
let srgb = |x: f64| if x <= 0.04045 { x / 12.92 } else { ((x + 0.055) / 1.055).powf(2.4) };
!matches!(self, Trc::Gamma(_)) && (0..=32).all(|i| (self.eval(i as f64 / 32.0) - srgb(i as f64 / 32.0)).abs() < 0.004)
}
}
/// What a matrix/TRC RGB profile says.
#[derive(Clone, Debug, PartialEq)]
pub struct RgbProfile {
pub description: String,
/// Red, green, blue chromaticities (CIE xy) under the profile's own white.
pub primaries: [[f64; 2]; 3],
/// The white point (CIE xy) of the device.
pub white: [f64; 2],
/// Tone curves (red, green, blue).
pub trc: [Trc; 3],
/// ICC version major number.
pub version: u8,
}
fn be32(b: &[u8], o: usize) -> Option<u32> {
Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?))
}
fn be16(b: &[u8], o: usize) -> Option<u16> {
Some(u16::from_be_bytes(b.get(o..o + 2)?.try_into().ok()?))
}
fn s15f16(b: &[u8], o: usize) -> Option<f64> {
Some(be32(b, o)? as i32 as f64 / 65536.0)
}
fn xy(v: [f64; 3]) -> [f64; 2] {
let s = v[0] + v[1] + v[2];
if s.abs() < 1e-12 { D50 } else { [v[0] / s, v[1] / s] }
}
fn xyz_of(c: [f64; 2]) -> [f64; 3] {
[c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]
}
/// Parse an RGB matrix/TRC ICC profile.
pub fn parse(b: &[u8]) -> Result<RgbProfile, String> {
if b.len() < 132 || b.get(36..40) != Some(b"acsp") {
return Err("not an ICC profile".into());
}
if b.get(16..20) != Some(b"RGB ") {
return Err("not an RGB profile (only RGB display profiles can be simulated)".into());
}
let version = b[8];
let n = be32(b, 128).ok_or("truncated tag table")? as usize;
let mut tags = std::collections::HashMap::new();
for i in 0..n.min(200) {
let o = 132 + i * 12;
let sig = b.get(o..o + 4).ok_or("truncated tag table")?;
let off = be32(b, o + 4).ok_or("truncated tag table")? as usize;
let size = be32(b, o + 8).ok_or("truncated tag table")? as usize;
if off.checked_add(size).is_some_and(|e| e <= b.len()) {
tags.insert(String::from_utf8_lossy(sig).into_owned(), &b[off..off + size]);
}
}
let xyz_tag = |name: &str| -> Result<[f64; 3], String> {
let t = tags.get(name).ok_or_else(|| format!("the profile has no {name} tag (only matrix/TRC profiles are supported)"))?;
if t.get(0..4) != Some(b"XYZ ") {
return Err(format!("{name}: not an XYZ tag"));
}
Ok([s15f16(t, 8).ok_or("bad XYZ")?, s15f16(t, 12).ok_or("bad XYZ")?, s15f16(t, 16).ok_or("bad XYZ")?])
};
let trc_tag = |name: &str| -> Result<Trc, String> {
let t = tags.get(name).ok_or_else(|| format!("the profile has no {name} tag"))?;
match t.get(0..4) {
Some(b"curv") => {
let count = be32(t, 8).ok_or("bad curv")? as usize;
match count {
0 => Ok(Trc::Gamma(1.0)),
1 => Ok(Trc::Gamma(be16(t, 12).ok_or("bad curv")? as f64 / 256.0)),
_ => Ok(Trc::Table((0..count).map(|i| be16(t, 12 + i * 2)).collect::<Option<Vec<_>>>().ok_or("truncated curv")?)),
}
}
Some(b"para") => {
let ty = be16(t, 8).ok_or("bad para")?;
let np = [1, 3, 4, 5, 7].get(ty as usize).copied().ok_or("unknown para function")?;
let mut p = [0.0; 7];
for (k, v) in p.iter_mut().enumerate().take(np) {
*v = s15f16(t, 12 + k * 4).ok_or("truncated para")?;
}
Ok(if ty == 0 { Trc::Gamma(p[0]) } else { Trc::Parametric(ty, p) })
}
_ => Err(format!("{name}: unsupported curve type")),
}
};
let col = [xyz_tag("rXYZ")?, xyz_tag("gXYZ")?, xyz_tag("bXYZ")?];
// The device white: from the chromatic adaptation (v4, and many v2 profiles), else the media
// white point (v2 profiles that keep it un-adapted), else D50.
let d50 = xyz_of(D50);
let chad = tags.get("chad").filter(|t| t.get(0..4) == Some(b"sf32") && t.len() >= 44).map(|t| {
let v: Vec<f64> = (0..9).map(|i| s15f16(t, 8 + i * 4).unwrap_or(0.0)).collect();
[[v[0], v[1], v[2]], [v[3], v[4], v[5]], [v[6], v[7], v[8]]]
});
let white = match (chad, xyz_tag("wtpt").ok()) {
(Some(m), _) => xy(mul_vec(&invert(&m), d50)),
(None, Some(w)) => xy(w),
_ => D50,
};
// Colorants are adapted to D50 in the profile: undo that to get the device primaries.
let undo = match chad {
Some(m) => invert(&m),
None => bradford(D50, white),
};
let primaries = col.map(|c| xy(mul_vec(&undo, c)));
let trc = [trc_tag("rTRC")?, trc_tag("gTRC")?, trc_tag("bTRC")?];
Ok(RgbProfile { description: description(tags.get("desc").copied()).unwrap_or_default(), primaries, white, trc, version })
}
fn description(t: Option<&[u8]>) -> Option<String> {
let t = t?;
match t.get(0..4)? {
b"desc" => {
let n = be32(t, 8)? as usize;
let s = t.get(12..12 + n)?;
Some(String::from_utf8_lossy(s).trim_end_matches('\0').to_string())
}
b"mluc" => {
// The first record: UTF-16BE.
let len = be32(t, 20)? as usize;
let off = be32(t, 24)? as usize;
let u: Vec<u16> = t.get(off..off + len)?.chunks_exact(2).map(|c| u16::from_be_bytes([c[0], c[1]])).collect();
Some(String::from_utf16_lossy(&u).trim_end_matches('\0').to_string())
}
_ => None,
}
}
/// Build a minimal v2 matrix/TRC RGB profile (tests, and saving a custom simulation): colorants
/// Bradford-adapted to D50, `wtpt` the device white, gamma curves.
pub fn write_matrix_profile(desc: &str, primaries: [[f64; 2]; 3], white: [f64; 2], gamma: f64) -> Vec<u8> {
let m = crate::space::rgb_to_xyz(primaries, white);
let a = bradford(white, D50);
let col = |j: usize| mul_vec(&a, [m[0][j], m[1][j], m[2][j]]);
let f = |v: f64| ((v * 65536.0).round() as i32).to_be_bytes();
let xyz = |v: [f64; 3]| {
let mut t = b"XYZ \0\0\0\0".to_vec();
for c in v {
t.extend(f(c));
}
t
};
let curv = {
let mut t = b"curv\0\0\0\0".to_vec();
t.extend(1u32.to_be_bytes());
t.extend(((gamma * 256.0).round() as u16).to_be_bytes());
t.extend([0, 0]);
t
};
let dsc = {
let mut t = b"desc\0\0\0\0".to_vec();
t.extend((desc.len() as u32 + 1).to_be_bytes());
t.extend(desc.as_bytes());
t.push(0);
t.extend([0u8; 12 + 67]);
while !t.len().is_multiple_of(4) {
t.push(0);
}
t
};
let w = xyz_of(white);
let tags: Vec<(&[u8; 4], Vec<u8>)> = vec![
(b"desc", dsc),
(b"wtpt", xyz(w)),
(b"rXYZ", xyz(col(0))),
(b"gXYZ", xyz(col(1))),
(b"bXYZ", xyz(col(2))),
(b"rTRC", curv.clone()),
(b"gTRC", curv.clone()),
(b"bTRC", curv),
];
let mut table = vec![];
let mut data = vec![];
let base = 128 + 4 + tags.len() * 12;
for (sig, t) in &tags {
table.extend(*sig);
table.extend(((base + data.len()) as u32).to_be_bytes());
table.extend((t.len() as u32).to_be_bytes());
data.extend(t);
while !data.len().is_multiple_of(4) {
data.push(0);
}
}
let total = base + data.len();
let mut h = vec![0u8; 128];
h[0..4].copy_from_slice(&(total as u32).to_be_bytes());
h[8] = 2;
h[9] = 0x10;
h[12..16].copy_from_slice(b"mntr");
h[16..20].copy_from_slice(b"RGB ");
h[20..24].copy_from_slice(b"XYZ ");
h[36..40].copy_from_slice(b"acsp");
for (k, v) in xyz_of(D50).into_iter().enumerate() {
h[68 + k * 4..72 + k * 4].copy_from_slice(&f(v));
}
let mut out = h;
out.extend((tags.len() as u32).to_be_bytes());
out.extend(table);
out.extend(data);
out
}
#[cfg(test)]
mod tests {
use super::*;
const P709: [[f64; 2]; 3] = [[0.64, 0.33], [0.30, 0.60], [0.15, 0.06]];
const D65: [f64; 2] = [0.3127, 0.3290];
#[test]
fn matrix_profile_round_trips() {
let b = write_matrix_profile("Test RGB", P709, D65, 2.2);
let p = parse(&b).unwrap();
assert_eq!(p.description, "Test RGB");
assert_eq!(p.version, 2);
for (a, e) in p.primaries.iter().zip(P709) {
assert!((a[0] - e[0]).abs() < 2e-4 && (a[1] - e[1]).abs() < 2e-4, "{a:?} vs {e:?}");
}
assert!((p.white[0] - D65[0]).abs() < 2e-4 && (p.white[1] - D65[1]).abs() < 2e-4, "{:?}", p.white);
assert!((p.trc[0].fit_gamma() - 2.2).abs() < 0.01);
assert!(!p.trc[0].is_srgb());
}
#[test]
fn curves() {
let srgb = Trc::Parametric(3, [2.4, 1.0 / 1.055, 0.055 / 1.055, 1.0 / 12.92, 0.04045, 0.0, 0.0]);
assert!(srgb.is_srgb());
assert!((srgb.fit_gamma() - 2.2).abs() < 0.15, "{}", srgb.fit_gamma());
let table = Trc::Table((0..256).map(|i| ((i as f64 / 255.0).powf(1.8) * 65535.0).round() as u16).collect());
assert!((table.fit_gamma() - 1.8).abs() < 0.02);
assert!((Trc::Gamma(2.0).eval(0.5) - 0.25).abs() < 1e-12);
}
#[test]
fn rejects_other_files() {
assert!(parse(b"hello").is_err());
let mut b = write_matrix_profile("x", P709, D65, 2.2);
b[16..20].copy_from_slice(b"CMYK");
assert!(parse(&b).unwrap_err().contains("RGB"));
}
}
+1
View File
@@ -6,6 +6,7 @@
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
pub mod blend;
pub mod icc;
pub mod space;
pub use blend::{BlendMode, blend_pixel};
+87 -2
View File
@@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p};
use crate::viewer::{Channel, SimProfile, Simulation, Snapshot};
use crate::viewer::{Channel, CustomRgb, SimProfile, Simulation, Snapshot};
use crate::{EngineError, Result, Session, VertexRef, cmd, query};
/// Composition ▸ Preview ▸ Fast Previews modes.
@@ -345,6 +345,81 @@ fn simulate_output(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb}))
}
fn custom_rgb_json(c: &CustomRgb) -> Value {
serde_json::to_value(c).unwrap_or_default()
}
/// View ▸ Simulate Output ▸ My Custom RGB…: define the custom output device (kept in Settings)
/// and, unless `apply` is false, simulate it. An `icc` path that differs from the one the
/// definition came from is read (its primaries, white and curve replace the numbers); `from`
/// starts from a built-in profile; `reset` goes back to the default.
fn custom_rgb(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "view.customRgb";
let mut c = if b_p(p, "reset") == Some(true) { CustomRgb::default() } else { s.prefs.custom_rgb.clone() };
if let Some(from) = str_p(p, "from") {
let prof = SimProfile::parse(from).ok_or_else(|| bad(cmd, format!("from: unknown profile `{from}`")))?;
if let Some((prims, curve)) = prof.space() {
[c.red, c.green, c.blue] = prims;
c.white = [0.3127, 0.3290];
(c.gamma, c.srgb_curve) = match curve {
crate::viewer::Curve::Srgb => (2.2, true),
crate::viewer::Curve::Gamma(g) => ((g as f64 * 1000.0).round() / 1000.0, false),
_ => (1.0, false),
};
c.icc.clear();
c.name = format!("Custom ({})", prof.label());
}
}
let pt = |k: &str| -> Result<Option<[f64; 2]>> {
match p.get(k) {
None | Some(Value::Null) => Ok(None),
Some(v) => {
let a = v.as_array().filter(|a| a.len() == 2).ok_or_else(|| bad(cmd, format!("{k}: [x, y]")))?;
let g = |i: usize| a[i].as_f64().ok_or_else(|| bad(cmd, format!("{k}: [x, y] numbers")));
Ok(Some([g(0)?, g(1)?]))
}
}
};
for (k, slot) in [("red", &mut c.red), ("green", &mut c.green), ("blue", &mut c.blue), ("white", &mut c.white)] {
if let Some(v) = pt(k)? {
*slot = v;
}
}
if let Some(g) = f_p(p, "gamma") {
c.gamma = g;
}
if let Some(b) = b_p(p, "srgbCurve") {
c.srgb_curve = b;
}
if let Some(n) = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty()) {
c.name = n.to_string();
}
match str_p(p, "icc").map(str::trim) {
Some("") => c.icc.clear(),
Some(path) if path != c.icc || b_p(p, "reload") == Some(true) => {
let bytes = s.services.read_file(path).map_err(|e| EngineError::Other(format!("cannot read {path}: {e}")))?;
let mut from = CustomRgb::from_icc(&bytes, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
if let Some(n) = str_p(p, "name").map(str::trim).filter(|n| !n.is_empty() && *n != s.prefs.custom_rgb.name) {
from.name = n.to_string();
}
c = from;
}
_ => {}
}
c.validate().map_err(|e| bad(cmd, e))?;
s.prefs.custom_rgb = c.clone();
s.prefs_revision += 1;
s.save_prefs();
if b_p(p, "apply") != Some(false) {
let preserve = b_p(p, "preserveRgb").unwrap_or(false);
s.state.viewer.simulation = Simulation { profile: SimProfile::MyCustom, preserve_rgb: preserve };
}
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
let mut out = custom_rgb_json(&c);
out["active"] = json!(s.state.viewer.simulation.profile == SimProfile::MyCustom);
Ok(out)
}
/// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active
/// one) and its current 3D view (or `view`).
fn split_locked(s: &mut Session, p: &Value) -> Result<Value> {
@@ -374,6 +449,7 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result<Value> {
Ok(json!({
"displayColorManagement": v.display_color_management,
"simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb},
"customRgb": custom_rgb_json(&s.prefs.custom_rgb),
"display": s.prefs.previews.display_profile,
"workingSpace": s.project.settings.working_space.map(|w| w.id()),
"active": crate::viewer::DisplayColor::of(s).is_some(),
@@ -390,10 +466,19 @@ pub fn specs() -> Vec<CommandSpec> {
"Simulate Output",
[],
None,
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}",
"{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|myCustom|custom, preserveRgb?, space? (custom)}",
always,
simulate_output
),
cmd!(
"view.customRgb",
"My Custom RGB...",
[],
None,
"{name?, red?/green?/blue?/white?: [x, y] (CIE xy), gamma?, srgbCurve?: bool, icc?: path (RGB ICC profile; read when it changes, \"\" clears), reload?, from?: a Simulate Output profile to start from, reset?, preserveRgb?, apply?: bool (default true: simulate it)} → the definition (kept in Settings)",
always,
custom_rgb
),
cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked),
cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked),
query!(
+5
View File
@@ -317,6 +317,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
"view.extendedViewer" => Some(s.prefs.three_d.extended_viewer),
"view.snapping" => Some(s.state.snapping),
"view.displayColorManagement" => Some(s.state.viewer.display_color_management),
"view.customRgb" => Some(s.state.viewer.simulation.profile == crate::viewer::SimProfile::MyCustom),
"view.simulateOutput" => {
let want = pstr("profile")?;
let sim = s.state.viewer.simulation;
@@ -482,6 +483,7 @@ pub const TREE: &str = r#"
File
New
New Project | file.newProject
New Project from Template... | file.newFromTemplate
New Folder | project.newFolder
Open Project... | file.open
Open Recent
@@ -496,6 +498,7 @@ File
Save As... | file.saveAs
Save a Copy... | file.saveCopy
Save a Copy As XML... | file.saveCopyAsXml
Save as Template... | templates.saveAs
Increment and Save | file.incrementAndSave
Revert | file.revert
---
@@ -1049,6 +1052,8 @@ View
UHDTV (Rec. 2020) | view.simulateOutput {"profile":"rec2020"}
Display P3 | view.simulateOutput {"profile":"p3"}
Linear (1.0 Gamma) | view.simulateOutput {"profile":"linear"}
---
My Custom RGB... | view.customRgb
Custom... | view.simulateOutput {"profile":"custom"}
---
Show Rulers | view.rulers
+3
View File
@@ -283,6 +283,8 @@ pub struct Prefs {
pub recent_fonts: Vec<String>,
/// Preview panel: every shortcut's options.
pub preview: crate::preview::PreviewSettings,
/// View ▸ Simulate Output ▸ My Custom RGB.
pub custom_rgb: crate::viewer::CustomRgb,
/// Top-level keys this version doesn't know (kept for newer versions).
#[serde(flatten)]
pub extra: Extra,
@@ -315,6 +317,7 @@ impl Default for Prefs {
recent_presets: vec![],
recent_fonts: vec![],
preview: crate::preview::PreviewSettings::default(),
custom_rgb: crate::viewer::CustomRgb::default(),
extra: Extra::new(),
}
}
+3
View File
@@ -746,6 +746,9 @@ pub fn specs() -> Vec<CommandSpec> {
save_as
),
cmd!("templates.delete", "Delete Template", [], None, "{id: user/<file>}", always, delete),
cmd!("file.newFromTemplate", "New Project from Template...", ["File", "New"], None, "{} → the Home screen's Templates tab", always, |s, p| {
crate::commands::frontend(s, "file.newFromTemplate", p)
}),
]
}
+65
View File
@@ -221,3 +221,68 @@ fn feet_and_frames_display_and_entry() {
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames);
assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16"));
}
/// View ▸ Simulate Output ▸ My Custom RGB…: typed-in primaries/white/gamma or an ICC profile,
/// kept in Settings.
#[test]
fn my_custom_rgb_simulation() {
let mut s = session();
s.config = Some(std::sync::Arc::new(crate::config::MemoryConfig::default()));
s.execute("file.projectSettings", json!({"workingSpace": "srgb"})).unwrap();
// The default definition: Rec. 709 primaries, D65, gamma 2.2.
let d = crate::viewer::CustomRgb::default();
assert_eq!((d.white, d.gamma), ([0.3127, 0.3290], 2.2));
// Rec. 709 / D65 / gamma 1.8 behaves exactly like Legacy Macintosh RGB.
s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap();
let mut a = px(128, 90, 200);
DisplayColor::of(&s).unwrap().apply(&mut a);
let r = s
.execute(
"view.customRgb",
json!({"name": "Old Mac", "red": [0.64, 0.33], "green": [0.30, 0.60], "blue": [0.15, 0.06], "white": [0.3127, 0.3290], "gamma": 1.8, "srgbCurve": false, "preserveRgb": true}),
)
.unwrap();
assert_eq!((r["name"].as_str(), r["active"].as_bool()), (Some("Old Mac"), Some(true)));
assert_eq!(s.state.viewer.simulation, Simulation { profile: SimProfile::MyCustom, preserve_rgb: true });
let mut b = px(128, 90, 200);
DisplayColor::of(&s).unwrap().apply(&mut b);
assert_eq!(a, b);
assert_eq!(crate::menus::checked(&s, "view.customRgb", &json!({})), Some(true));
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "myCustom"})), Some(true));
// Persisted in Settings.
let saved = s.config.as_ref().unwrap().read(crate::prefs::PREFS_FILE).unwrap();
assert!(saved.contains("\"customRgb\"") && saved.contains("Old Mac"), "{saved}");
// Start from a built-in profile.
s.execute("view.customRgb", json!({"from": "rec2020", "preserveRgb": false})).unwrap();
assert_eq!(s.prefs.custom_rgb.red, [0.708, 0.292]);
// An ICC profile: the primaries, white and curve come from the file.
let p3 = [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]];
let icc = effectcraft_color::icc::write_matrix_profile("Studio Monitor", p3, [0.3127, 0.3290], 2.4);
let path = std::env::temp_dir().join(format!("ec-custom-{}.icc", std::process::id()));
std::fs::write(&path, icc).unwrap();
let r = s.execute("view.customRgb", json!({"icc": path.to_string_lossy()})).unwrap();
let _ = std::fs::remove_file(&path);
assert_eq!(r["name"], "Studio Monitor");
let c = s.prefs.custom_rgb.clone();
assert!((c.green[0] - 0.265).abs() < 1e-3 && (c.green[1] - 0.690).abs() < 1e-3, "{c:?}");
assert!((c.gamma - 2.4).abs() < 0.01 && !c.srgb_curve);
assert!(DisplayColor::of(&s).is_some());
// The same path again keeps typed-in edits; an empty path forgets the profile.
s.execute("view.customRgb", json!({"icc": c.icc, "gamma": 2.0})).unwrap();
assert_eq!(s.prefs.custom_rgb.gamma, 2.0);
s.execute("view.customRgb", json!({"icc": ""})).unwrap();
assert!(s.prefs.custom_rgb.icc.is_empty());
// Bad definitions are refused and change nothing.
let before = s.prefs.custom_rgb.clone();
assert!(s.execute("view.customRgb", json!({"red": [0.3, 0.6], "green": [0.3, 0.6]})).is_err());
assert!(s.execute("view.customRgb", json!({"gamma": 0.0})).is_err());
assert!(s.execute("view.customRgb", json!({"white": [1.2, 0.3]})).is_err());
assert!(s.execute("view.customRgb", json!({"icc": "/no/such/profile.icc"})).is_err());
assert_eq!(s.prefs.custom_rgb, before);
// Define without simulating it.
s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap();
s.execute("view.customRgb", json!({"reset": true, "apply": false})).unwrap();
assert_eq!(s.state.viewer.simulation.profile, SimProfile::None);
assert_eq!(s.prefs.custom_rgb, crate::viewer::CustomRgb::default());
assert_eq!(s.execute("view.displayColor", json!({})).unwrap()["customRgb"]["gamma"], json!(2.2));
}
+120 -8
View File
@@ -383,6 +383,8 @@ pub enum SimProfile {
Pal,
/// Legacy Macintosh RGB: Rec. 709 primaries, gamma 1.8.
Mac18,
/// My Custom RGB: the user's own device ([`CustomRgb`], in Settings).
MyCustom,
}
/// A transfer curve of a simulated or display profile.
@@ -421,7 +423,7 @@ impl Curve {
const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
impl SimProfile {
pub const ALL: [SimProfile; 9] = [
pub const ALL: [SimProfile; 10] = [
SimProfile::None,
SimProfile::Rec709,
SimProfile::Ntsc,
@@ -431,6 +433,7 @@ impl SimProfile {
SimProfile::Rec2020,
SimProfile::P3,
SimProfile::Linear,
SimProfile::MyCustom,
];
pub fn id(self) -> &'static str {
@@ -444,6 +447,7 @@ impl SimProfile {
SimProfile::Ntsc => "ntsc",
SimProfile::Pal => "pal",
SimProfile::Mac18 => "mac18",
SimProfile::MyCustom => "myCustom",
}
}
@@ -458,6 +462,7 @@ impl SimProfile {
SimProfile::Ntsc => "SDTV NTSC",
SimProfile::Pal => "SDTV PAL",
SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)",
SimProfile::MyCustom => "My Custom RGB",
}
}
@@ -465,7 +470,7 @@ impl SimProfile {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
SimProfile::ALL
.into_iter()
.find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
.or(match k.as_str() {
"off" => Some(SimProfile::None),
"hdtv" | "bt709" => Some(SimProfile::Rec709),
@@ -476,10 +481,10 @@ impl SimProfile {
})
}
/// (primaries, curve); `None` for No Output Simulation.
/// (primaries, curve); `None` for No Output Simulation and My Custom RGB (see [`CustomRgb`]).
pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> {
Some(match self {
SimProfile::None => return None,
SimProfile::None | SimProfile::MyCustom => return None,
SimProfile::Rec709 => (P709, Curve::Gamma(2.4)),
SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)),
SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb),
@@ -501,6 +506,93 @@ pub struct Simulation {
pub preserve_rgb: bool,
}
/// View ▸ Simulate Output ▸ My Custom RGB…: a user-defined output device, kept in Settings
/// (`customRgb`). Defined by primaries and white point (CIE xy) and a gamma or the sRGB curve,
/// typed in or read from an RGB ICC profile.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CustomRgb {
pub name: String,
pub red: [f64; 2],
pub green: [f64; 2],
pub blue: [f64; 2],
pub white: [f64; 2],
pub gamma: f64,
/// The sRGB piecewise curve instead of a pure power curve.
pub srgb_curve: bool,
/// The ICC profile the numbers were read from (empty: typed in).
pub icc: String,
}
impl Default for CustomRgb {
fn default() -> Self {
CustomRgb { name: "My Custom RGB".into(), red: P709[0], green: P709[1], blue: P709[2], white: D65, gamma: 2.2, srgb_curve: false, icc: String::new() }
}
}
impl CustomRgb {
pub fn primaries(&self) -> [[f64; 2]; 3] {
[self.red, self.green, self.blue]
}
pub fn curve(&self) -> Curve {
if self.srgb_curve {
Curve::Srgb
} else if (self.gamma - 1.0).abs() < 1e-9 {
Curve::Linear
} else {
Curve::Gamma(self.gamma as f32)
}
}
/// Chromaticities inside (0, 1) with y > 0, a gamma in 0.1–10, and primaries that span a
/// triangle.
pub fn validate(&self) -> std::result::Result<(), String> {
for (n, c) in [("red", self.red), ("green", self.green), ("blue", self.blue), ("white", self.white)] {
if !(c[0] > 0.0 && c[1] > 0.0 && c[0] < 1.0 && c[1] < 1.0 && c[0] + c[1] <= 1.0) {
return Err(format!("{n}: chromaticity x, y must be in (0, 1) with x + y ≤ 1"));
}
}
if !self.srgb_curve && !(0.1..=10.0).contains(&self.gamma) {
return Err("gamma must be between 0.1 and 10".into());
}
let [r, g, b] = self.primaries();
let area = (g[0] - r[0]) * (b[1] - r[1]) - (b[0] - r[0]) * (g[1] - r[1]);
if area.abs() < 1e-6 {
return Err("the primaries must form a triangle".into());
}
Ok(())
}
/// Take primaries, white and curve from an RGB ICC profile.
pub fn from_icc(bytes: &[u8], path: &str) -> std::result::Result<CustomRgb, String> {
let p = effectcraft_color::icc::parse(bytes)?;
let name = if p.description.trim().is_empty() {
std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "ICC Profile".into())
} else {
p.description.trim().to_string()
};
let gamma = (p.trc.iter().map(|t| t.fit_gamma()).sum::<f64>() / 3.0 * 1000.0).round() / 1000.0;
let r = |c: [f64; 2]| c.map(|v| (v * 1e5).round() / 1e5);
Ok(CustomRgb {
name,
red: r(p.primaries[0]),
green: r(p.primaries[1]),
blue: r(p.primaries[2]),
white: r(p.white),
gamma,
srgb_curve: p.trc[0].is_srgb(),
icc: path.to_string(),
})
}
fn prof(&self) -> Prof {
let m = space::rgb_to_xyz(self.primaries(), self.white);
let xyz = if self.white == D65 { m } else { space::mul(&space::bradford(self.white, D65), &m) };
Prof { xyz, curve: self.curve() }
}
}
/// An RGB space for viewer conversions: primaries (D65) and curve.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Prof {
@@ -562,7 +654,16 @@ impl DisplayColor {
/// After Effects); `linear`: the working space is linear; `source`: the space rendered
/// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color
/// Management; `display`: the monitor's space.
pub fn new(working: Option<ColorSpace>, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option<DisplayColor> {
#[allow(clippy::too_many_arguments)]
pub fn new(
working: Option<ColorSpace>,
linear: bool,
source: ColorSpace,
dcm: bool,
display: ColorSpace,
sim: Simulation,
custom: &CustomRgb,
) -> Option<DisplayColor> {
let ws = working?;
let src = Prof::of(source, false);
if !dcm {
@@ -570,8 +671,11 @@ impl DisplayColor {
let raw = Conv::new(src, Prof::of(ws, linear));
return Some(DisplayColor { sim: None, display: Some(raw) });
}
let sim = sim.profile.space().map(|(prims, curve)| {
let out = Prof::new(prims, curve);
let out = match sim.profile {
SimProfile::MyCustom => Some(custom.prof()),
p => p.space().map(|(prims, curve)| Prof::new(prims, curve)),
};
let sim = out.map(|out| {
(
Conv::new(src, out),
Some(if sim.preserve_rgb {
@@ -591,7 +695,15 @@ impl DisplayColor {
let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb);
let v = &s.state.viewer;
let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear());
DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation)
DisplayColor::new(
st.working_space,
linear,
st.output_space.unwrap_or(ColorSpace::Srgb),
v.display_color_management,
display,
v.simulation,
&s.prefs.custom_rgb,
)
}
/// One straight colour (0..1 floats).