From 9e70cde72572c123ebe898822c4b75fb95ac8a7b Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 17:46:41 -0400 Subject: [PATCH] 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 --- crates/color/src/icc.rs | 337 ++++++++++++++++++++++ crates/color/src/lib.rs | 1 + crates/engine/src/commands/viewer_cmds.rs | 89 +++++- crates/engine/src/menus.rs | 5 + crates/engine/src/prefs.rs | 3 + crates/engine/src/templates.rs | 3 + crates/engine/src/tests_color_view.rs | 65 +++++ crates/engine/src/viewer.rs | 128 +++++++- 8 files changed, 621 insertions(+), 10 deletions(-) create mode 100644 crates/color/src/icc.rs diff --git a/crates/color/src/icc.rs b/crates/color/src/icc.rs new file mode 100644 index 00000000..b740c123 --- /dev/null +++ b/crates/color/src/icc.rs @@ -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), + /// `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 { + Some(u32::from_be_bytes(b.get(o..o + 4)?.try_into().ok()?)) +} +fn be16(b: &[u8], o: usize) -> Option { + Some(u16::from_be_bytes(b.get(o..o + 2)?.try_into().ok()?)) +} +fn s15f16(b: &[u8], o: usize) -> Option { + 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 { + 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 { + 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::>>().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 = (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 { + 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 = 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 { + 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)> = 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")); + } +} diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 9212b020..fd94e5a8 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -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}; diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index b8b36f15..30dc6e4a 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -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 { 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 { + 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> { + 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 { @@ -374,6 +449,7 @@ fn display_color_state(s: &mut Session, _: &Value) -> Result { 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 { "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!( diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index b991be9b..0cfb9227 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -317,6 +317,7 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "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 diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 8e73c0ba..9524924a 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -283,6 +283,8 @@ pub struct Prefs { pub recent_fonts: Vec, /// 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(), } } diff --git a/crates/engine/src/templates.rs b/crates/engine/src/templates.rs index 3800b235..3829ec39 100644 --- a/crates/engine/src/templates.rs +++ b/crates/engine/src/templates.rs @@ -746,6 +746,9 @@ pub fn specs() -> Vec { save_as ), cmd!("templates.delete", "Delete Template", [], None, "{id: user/}", 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) + }), ] } diff --git a/crates/engine/src/tests_color_view.rs b/crates/engine/src/tests_color_view.rs index c0f83d66..7d42f739 100644 --- a/crates/engine/src/tests_color_view.rs +++ b/crates/engine/src/tests_color_view.rs @@ -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)); +} diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index bba10122..22385995 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -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::().to_ascii_lowercase(); SimProfile::ALL .into_iter() - .find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() == k) + .find(|p| p.id().to_ascii_lowercase() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::().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 { + 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::() / 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, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option { + #[allow(clippy::too_many_arguments)] + pub fn new( + working: Option, + linear: bool, + source: ColorSpace, + dcm: bool, + display: ColorSpace, + sim: Simulation, + custom: &CustomRgb, + ) -> Option { 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).