mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 19:47:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b760f1d23a
commit
9e70cde725
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user