From d5eb44e9fd7b621ca040ada240e689c2d636dc1c Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sun, 4 Oct 2026 15:44:13 -0400 Subject: [PATCH] Colour-managed canvas: document profile -> monitor (P3/Adobe RGB/ProPhoto/Gray/CMYK), linear EXR, embedded CMYK (#66) * CMYK documents display and export through their embedded CMYK profile A thread-local CmykSpace (photocraft_color::convert::with_cmyk_space) replaces the built-in coated CMYK while the CPU compositor, the GPU compositor's uploads and the PSD merged composite convert a document's CMYK pixels; GPU residents track the profile they were converted with. Refs #46 Co-Authored-By: Claude Opus 5.5 * EXR/HDR are linear: tag imports linear sRGB, linearise exports Refs #46 Co-Authored-By: Claude Opus 5.5 * Colour-managed canvas: document profile -> monitor profile on GPU and CPU paths * engine display_color: CanvasDisplay per (document profile, mode, monitor), identity fast path when they match (sRGB on sRGB costs nothing); linear composites are stored sRGB-encoded in the 8-bit texture. canvas_lut now always carries the CM transform, with Proof Colors / Gamut Warning / 32-bit preview on top. * GPU canvas applies the display LUT whenever the transform isn't the identity (also for filter/proxy previews); the CPU canvas and Navigator transform the 8-bit composite. * Edit > Color Settings > Monitor Profile (auto|built-in|.icc). macOS 'auto' reads the main display's ICC profile at launch via osascript/AppKit (no unsafe); elsewhere sRGB. * Tests: P3 (hand-computed), Adobe RGB, ProPhoto, Gray Gamma 2.2, synthetic CMYK, linear, monitor setting; GPU+CPU canvas test via kittest (skips without Rgba32Float). Fixes #46 Co-Authored-By: Claude Opus 5.5 * io: tag EXR/HDR linear in import_flat (adapt to the shared image_to_document) #65 moved flat decoding into image_to_document(name, img) so camera raws can reuse it; the linear-EXR tagging needs the file bytes for format detection, so it now runs in import_flat. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- apps/photocraft/src/main.rs | 6 + apps/photocraft/src/monitor_profile.rs | 98 ++++++++ crates/color/src/convert.rs | 171 +++++++++++-- crates/compose/src/lib.rs | 41 ++- crates/engine/examples/bench_display.rs | 76 ++++++ crates/engine/src/color_cmds.rs | 61 +++-- crates/engine/src/display_color.rs | 236 +++++++++++++++++ crates/engine/src/display_color_tests.rs | 251 +++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/proof_sim.rs | 8 +- crates/gpu/src/lib.rs | 25 +- crates/io/src/flat.rs | 33 ++- crates/io/src/psd_export.rs | 26 +- crates/io/tests/flat.rs | 23 +- crates/ui-egui/src/canvas.rs | 120 +++++---- crates/ui-egui/src/gpu_canvas.rs | 33 ++- crates/ui-egui/tests/color_managed_canvas.rs | 80 ++++++ docs/architecture.md | 3 +- 18 files changed, 1176 insertions(+), 116 deletions(-) create mode 100644 apps/photocraft/src/monitor_profile.rs create mode 100644 crates/engine/examples/bench_display.rs create mode 100644 crates/engine/src/display_color.rs create mode 100644 crates/engine/src/display_color_tests.rs create mode 100644 crates/ui-egui/tests/color_managed_canvas.rs diff --git a/apps/photocraft/src/main.rs b/apps/photocraft/src/main.rs index 769ad2a44e..ad9761ebea 100644 --- a/apps/photocraft/src/main.rs +++ b/apps/photocraft/src/main.rs @@ -16,6 +16,7 @@ mod apple_events; mod control_server; mod crash_guard; +mod monitor_profile; mod services; use photocraft_engine::Session; @@ -83,6 +84,8 @@ fn main() -> eframe::Result { #[cfg(target_os = "macos")] let apple_events = &apple_events; + // Read the main display's ICC profile while the window opens (colour-managed canvas). + let monitor = monitor_profile::detect_async(); let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_icon(app_icon()) @@ -102,6 +105,9 @@ fn main() -> eframe::Result { Box::new(move |cc| { let mut app = PhotocraftApp::new(Session::new(), services::native()); app.integrated_titlebar = cfg!(target_os = "macos"); + if let Ok(Some(icc)) = monitor.recv_timeout(std::time::Duration::from_secs(2)) { + app.session.color.monitor_profile = Some(std::sync::Arc::new(icc)); + } // Preferences › Performance › Use Graphics Processor. if let Some(rs) = cc.wgpu_render_state.clone() && std::env::var_os("PHOTOCRAFT_CPU_CANVAS").is_none() diff --git a/apps/photocraft/src/monitor_profile.rs b/apps/photocraft/src/monitor_profile.rs new file mode 100644 index 0000000000..08492c7308 --- /dev/null +++ b/apps/photocraft/src/monitor_profile.rs @@ -0,0 +1,98 @@ +//! The main display's ICC profile, for the colour-managed canvas (Edit › Color Settings › +//! Monitor Profile = `auto`). +//! +//! macOS: `NSScreen.mainScreen.colorSpace.ICCProfileData`, a documented AppKit API, read through +//! `osascript` (AppleScriptObjC) so the app needs no `unsafe` FFI. The query runs on a background +//! thread at launch (about 0.4 s) and the profile is applied when it arrives. Other platforms +//! return `None` (sRGB, or the profile chosen in Color Settings). + +use std::sync::mpsc::Receiver; + +/// Starts reading the main display's profile in the background. +pub fn detect_async() -> Receiver>> { + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let _ = tx.send(detect()); + }); + rx +} + +#[cfg(target_os = "macos")] +fn detect() -> Option> { + let out = std::process::Command::new("/usr/bin/osascript") + .args([ + "-e", + "use framework \"AppKit\"", + "-e", + "return ((current application's NSScreen's mainScreen()'s colorSpace()'s ICCProfileData()'s base64EncodedStringWithOptions:0) as text)", + ]) + .stderr(std::process::Stdio::null()) + .output() + .ok()?; + if !out.status.success() { + return None; + } + let bytes = base64_decode(std::str::from_utf8(&out.stdout).ok()?.trim())?; + // An ICC profile starts with its size and carries `acsp` at offset 36. + (bytes.len() >= 132 && bytes.get(36..40) == Some(b"acsp")).then_some(bytes) +} + +#[cfg(not(target_os = "macos"))] +fn detect() -> Option> { + None +} + +/// Standard base64 (RFC 4648, with padding) → bytes; `None` on any invalid character. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn base64_decode(s: &str) -> Option> { + let val = |c: u8| -> Option { + Some(match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + } as u32) + }; + let s = s.trim_end_matches('=').as_bytes(); + let mut out = Vec::with_capacity(s.len() * 3 / 4); + for chunk in s.chunks(4) { + let mut acc = 0u32; + for (i, c) in chunk.iter().enumerate() { + acc |= val(*c)? << (18 - 6 * i); + } + let n = match chunk.len() { + 4 => 3, + 3 => 2, + 2 => 1, + _ => return None, + }; + out.extend_from_slice(&acc.to_be_bytes()[1..1 + n]); + } + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64() { + assert_eq!(base64_decode("aGVsbG8=").as_deref(), Some(&b"hello"[..])); + assert_eq!(base64_decode("aGVsbG8h").as_deref(), Some(&b"hello!"[..])); + assert_eq!(base64_decode("aGk=").as_deref(), Some(&b"hi"[..])); + assert_eq!(base64_decode("").as_deref(), Some(&b""[..])); + assert!(base64_decode("a").is_none()); + assert!(base64_decode("a$==").is_none()); + } + + /// The detected profile (when there is one) parses as an RGB profile. + #[test] + fn detected_profile_parses() { + if let Some(bytes) = detect() { + let p = photocraft_engine::color_cmds::profile_from_bytes(&std::sync::Arc::new(bytes)).expect("parses"); + assert_eq!(format!("{:?}", p.color_space), "Rgb", "{}", p.description); + } + } +} diff --git a/crates/color/src/convert.rs b/crates/color/src/convert.rs index 70d52279e9..77fb1a62a2 100644 --- a/crates/color/src/convert.rs +++ b/crates/color/src/convert.rs @@ -3,13 +3,91 @@ //! CMYK ↔ RGB goes through the ICC colour management module (`photocraft-cms`) with the //! built-in coated CMYK profile and sRGB (relative colorimetric with black point //! compensation, Photoshop's default), so CMYK pixels display and accept painted colours the -//! way a colour-managed editor does. Document-specific profiles are applied by the engine's -//! mode/profile conversions; these per-pixel helpers always assume the built-in profiles. +//! way a colour-managed editor does. A document's embedded CMYK profile replaces the built-in +//! one while its [`CmykSpace`] is active on the thread ([`with_cmyk_space`]: the compositor, +//! the GPU upload and composite exports enter it); otherwise the built-in profiles apply. //! Lab uses the exact D50 formulas; gray is treated as sGray (gray `v` = sRGB `(v, v, v)`). -use std::sync::OnceLock; +use std::cell::RefCell; +use std::sync::{Arc, Mutex, OnceLock, Weak}; -use photocraft_cms::{Builtin, Intent, Transform}; +use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform}; + +/// A document's own CMYK profile, linked to sRGB both ways (relative colorimetric + BPC, like +/// the built-in defaults). While a space is active on a thread ([`with_cmyk_space`]), +/// [`cmyk_to_rgb`] and [`rgb_to_cmyk`] (and so `Surface::rgba`, the compositor's reads of CMYK +/// layers and CMYK fill colours) use it instead of the built-in coated CMYK. +#[derive(Debug)] +pub struct CmykSpace { + to_srgb: Transform, + from_srgb: Transform, + /// Content hash of the profile (cache keys of converted pixels). + pub id: u64, +} + +impl CmykSpace { + /// The space of a document's embedded profile bytes: `None` when there are none, they do + /// not parse as a CMYK profile, or they are the built-in coated CMYK (the default path). + /// Cached by allocation, so calling this per render is cheap. + pub fn for_profile(icc: Option<&Arc>>) -> Option> { + type Cache = Mutex>, Option>)>>; + static CACHE: OnceLock = OnceLock::new(); + let bytes = icc?; + let cache = CACHE.get_or_init(Default::default); + { + let mut c = cache.lock().unwrap_or_else(|e| e.into_inner()); + c.retain(|(w, _)| w.strong_count() > 0); + if let Some((_, s)) = c.iter().find(|(w, _)| w.as_ptr() == Arc::as_ptr(bytes)) { + return s.clone(); + } + } + let space = Self::build(bytes); + cache.lock().unwrap_or_else(|e| e.into_inner()).push((Arc::downgrade(bytes), space.clone())); + space + } + + fn build(bytes: &[u8]) -> Option> { + let p = Profile::parse(bytes).ok().filter(|p| p.color_space == ColorSpace::Cmyk)?; + let coated = Builtin::CoatedCmyk.profile(); + if p.content_hash() == coated.content_hash() { + return None; + } + let srgb = Builtin::Srgb.profile(); + let to_srgb = Transform::new(&p, srgb, Intent::RelativeColorimetric, true).ok()?; + let from_srgb = Transform::new(srgb, &p, Intent::RelativeColorimetric, true).ok()?; + Some(Arc::new(CmykSpace { to_srgb, from_srgb, id: p.content_hash() })) + } +} + +thread_local! { + static ACTIVE_CMYK: RefCell>> = const { RefCell::new(None) }; +} + +/// Restores the previously active CMYK space when dropped (also on unwind). +struct Restore(Option>); + +impl Drop for Restore { + fn drop(&mut self) { + let prev = self.0.take(); + ACTIVE_CMYK.with(|a| *a.borrow_mut() = prev); + } +} + +/// Runs `f` with `space` as this thread's CMYK profile (`None`: the built-in coated CMYK). +/// Thread-local: parallel workers must enter the scope themselves. +pub fn with_cmyk_space(space: Option<&Arc>, f: impl FnOnce() -> R) -> R { + if space.is_none() && ACTIVE_CMYK.with(|a| a.borrow().is_none()) { + return f(); + } + let prev = ACTIVE_CMYK.with(|a| std::mem::replace(&mut *a.borrow_mut(), space.cloned())); + let _restore = Restore(prev); + f() +} + +/// The CMYK space active on this thread (see [`with_cmyk_space`]). +pub fn active_cmyk_space() -> Option> { + ACTIVE_CMYK.with(|a| a.borrow().clone()) +} // The built-in profiles always link (the tests use these transforms); `None` only guards // against a cms regression, and then the conversions fall back to the naive formulas. @@ -23,25 +101,47 @@ fn srgb_to_cmyk_transform() -> Option<&'static Transform> { T.get_or_init(|| Transform::new(Builtin::Srgb.profile(), Builtin::CoatedCmyk.profile(), Intent::RelativeColorimetric, true).ok()).as_ref() } -/// Colour-managed CMYK → sRGB (built-in coated CMYK profile, relative colorimetric + BPC). +/// Colour-managed CMYK → sRGB (the active document CMYK profile, else the built-in coated +/// CMYK; relative colorimetric + BPC). #[inline] pub fn cmyk_to_rgb(c: [f32; 4]) -> [f32; 3] { - let Some(t) = cmyk_to_srgb_transform() else { - return cmyk_to_rgb_naive(c); - }; let mut o = [0.0f32; 3]; - t.eval_fast(&c, &mut o); + // The active document's embedded CMYK profile, if one is in scope. + let active = ACTIVE_CMYK.with(|a| match &*a.borrow() { + Some(s) => { + s.to_srgb.eval_fast(&c, &mut o); + true + } + None => false, + }); + if !active { + let Some(t) = cmyk_to_srgb_transform() else { + return cmyk_to_rgb_naive(c); + }; + t.eval_fast(&c, &mut o); + } o } -/// Colour-managed sRGB → CMYK (built-in coated CMYK profile, relative colorimetric + BPC). +/// Colour-managed sRGB → CMYK (the active document CMYK profile, else the built-in coated +/// CMYK; relative colorimetric + BPC). #[inline] pub fn rgb_to_cmyk(rgb: [f32; 3]) -> [f32; 4] { - let Some(t) = srgb_to_cmyk_transform() else { - return rgb_to_cmyk_naive(rgb); - }; + let v = [rgb[0].clamp(0.0, 1.0), rgb[1].clamp(0.0, 1.0), rgb[2].clamp(0.0, 1.0)]; let mut o = [0.0f32; 4]; - t.eval_fast(&[rgb[0].clamp(0.0, 1.0), rgb[1].clamp(0.0, 1.0), rgb[2].clamp(0.0, 1.0)], &mut o); + let active = ACTIVE_CMYK.with(|a| match &*a.borrow() { + Some(s) => { + s.from_srgb.eval_fast(&v, &mut o); + true + } + None => false, + }); + if !active { + let Some(t) = srgb_to_cmyk_transform() else { + return rgb_to_cmyk_naive(rgb); + }; + t.eval_fast(&v, &mut o); + } o } @@ -149,6 +249,49 @@ mod tests { } } + #[test] + fn document_cmyk_space_scope() { + // A synthetic uncoated-like profile (heavier dot gain than the built-in coated one). + let params = photocraft_cms::synth::CmykParams { + description: "Test Uncoated".into(), + tvi: [0.26, 0.26, 0.26, 0.3], + grid_a2b: 5, + grid_b2a: 9, + ..Default::default() + }; + let p = photocraft_cms::synth::cmyk_profile(¶ms); + let bytes = p.to_bytes(); + let space = CmykSpace::for_profile(Some(&bytes)).expect("a CMYK profile other than the default"); + // Cached by allocation. + assert!(Arc::ptr_eq(&space, &CmykSpace::for_profile(Some(&bytes)).unwrap())); + // The built-in profile, absent or non-CMYK bytes keep the default path. + assert!(CmykSpace::for_profile(Some(&Builtin::CoatedCmyk.profile().to_bytes())).is_none()); + assert!(CmykSpace::for_profile(Some(&Builtin::Srgb.profile().to_bytes())).is_none()); + assert!(CmykSpace::for_profile(Some(&Arc::new(vec![1, 2, 3]))).is_none()); + assert!(CmykSpace::for_profile(None).is_none()); + let ink = [0.0, 0.5, 0.5, 0.0]; + let default = cmyk_to_rgb(ink); + let mut expect = [0.0f32; 3]; + Transform::new(&p, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap().eval(&ink, &mut expect); + let inside = with_cmyk_space(Some(&space), || { + assert!(active_cmyk_space().is_some()); + cmyk_to_rgb(ink) + }); + for i in 0..3 { + assert!((inside[i] - expect[i]).abs() < 0.01, "{inside:?} vs {expect:?}"); + } + assert!((0..3).any(|i| (inside[i] - default[i]).abs() > 0.02), "the document profile changes the colour: {inside:?} {default:?}"); + // Restored afterwards (and nested scopes restore the outer one). + assert!(active_cmyk_space().is_none()); + assert_eq!(cmyk_to_rgb(ink), default); + with_cmyk_space(Some(&space), || { + with_cmyk_space(None, || assert!(active_cmyk_space().is_none())); + assert!(active_cmyk_space().is_some()); + let back = cmyk_to_rgb(rgb_to_cmyk([0.6, 0.4, 0.3])); + assert!((back[0] - 0.6).abs() < 0.04 && (back[2] - 0.3).abs() < 0.04, "{back:?}"); + }); + } + #[test] fn lab_roundtrip_and_anchors() { let white = srgb_to_lab([1.0, 1.0, 1.0]); diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 6a942d4e8c..855bb44440 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -10,7 +10,9 @@ //! - fill layers, Dissolve //! //! This is the reference the GPU backend (milestone M5) must match within 1/255. Compositing -//! currently happens in display RGB. Mode-native (CMYK/Lab) compositing arrives with ICC in M8. +//! currently happens in display RGB. CMYK layers are read through the document's CMYK profile +//! (the built-in coated CMYK when untagged) and composited in sRGB; mode-native (CMYK/Lab) +//! compositing arrives with ICC in M8. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] @@ -91,12 +93,17 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { }; // Lab documents mix Normal blending in CIELAB, as Photoshop does (psblend::LAB_MIX). let lab = doc.mode == photocraft_color::ColorMode::Lab; + // CMYK layers are read through the document's own CMYK profile (thread-local scope). + let cmyk = cmyk_space(doc); + let cmyk = cmyk.as_ref(); if rect.width() as i32 <= tile && rect.height() as i32 <= tile { - let mut buf = multichannel::backdrop(doc, rect); - psblend::LAB_MIX.with(|l| l.set(lab)); - composite_stack(&doc.layers, &mut buf, &cx); - psblend::LAB_MIX.with(|l| l.set(false)); - return buf; + return photocraft_color::convert::with_cmyk_space(cmyk, || { + let mut buf = multichannel::backdrop(doc, rect); + psblend::LAB_MIX.with(|l| l.set(lab)); + composite_stack(&doc.layers, &mut buf, &cx); + psblend::LAB_MIX.with(|l| l.set(false)); + buf + }); } let mut tiles = Vec::new(); let mut y = rect.y0; @@ -109,11 +116,13 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { y += tile; } let run = |t: &Rect| { - let mut b = multichannel::backdrop(doc, *t); - psblend::LAB_MIX.with(|l| l.set(lab)); - composite_stack(&doc.layers, &mut b, &cx); - psblend::LAB_MIX.with(|l| l.set(false)); - b + photocraft_color::convert::with_cmyk_space(cmyk, || { + let mut b = multichannel::backdrop(doc, *t); + psblend::LAB_MIX.with(|l| l.set(lab)); + composite_stack(&doc.layers, &mut b, &cx); + psblend::LAB_MIX.with(|l| l.set(false)); + b + }) }; #[cfg(not(target_arch = "wasm32"))] let parts: Vec = { @@ -134,6 +143,16 @@ pub fn render_tiled(doc: &Document, rect: Rect, tile: i32) -> Buffer { out } +/// The document's own CMYK profile for reading its CMYK pixels (`None`: not a CMYK document, +/// untagged, or the built-in coated CMYK). Enter it with `photocraft_color::convert::with_cmyk_space` +/// around code that converts the document's CMYK pixels or colours to RGB. +pub fn cmyk_space(doc: &Document) -> Option> { + if doc.mode != photocraft_color::ColorMode::Cmyk { + return None; + } + photocraft_color::convert::CmykSpace::for_profile(doc.icc_profile.as_ref()) +} + /// Composite the full canvas. pub fn flatten(doc: &Document) -> Buffer { render(doc, doc.bounds()) diff --git a/crates/engine/examples/bench_display.rs b/crates/engine/examples/bench_display.rs new file mode 100644 index 0000000000..246c41a4b3 --- /dev/null +++ b/crates/engine/examples/bench_display.rs @@ -0,0 +1,76 @@ +//! Colour-managed canvas display cost on a 36 MP document (#46). +//! +//! ```sh +//! cargo run --release -p photocraft-engine --example bench_display -- [--size 7360x4912] [--reps 5] +//! ``` +//! +//! Times, per profile: the composite (shared by every path), the CPU canvas conversion before +//! (`Buffer::to_rgba8`, no colour management) and after (`CanvasDisplay::to_rgba8`), the +//! per-frame display lookup (`display_signature`, what the GPU canvas checks each frame) and +//! building the 33³ GPU display LUT (once per profile/monitor change). + +use std::time::Instant; + +use photocraft_cms::Builtin; +use photocraft_color::{Color, ColorMode, SampleType}; +use photocraft_doc::{Document, Size}; +use photocraft_engine::Session; + +fn arg(args: &[String], name: &str) -> Option { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned()) +} + +fn median(mut v: Vec) -> f64 { + v.sort_by(|a, b| a.total_cmp(b)); + v[v.len() / 2] +} + +fn time(reps: usize, mut f: impl FnMut()) -> f64 { + median( + (0..reps) + .map(|_| { + let t = Instant::now(); + f(); + t.elapsed().as_secs_f64() * 1000.0 + }) + .collect(), + ) +} + +fn main() { + let args: Vec = std::env::args().collect(); + let (w, h) = + arg(&args, "--size").and_then(|s| s.split_once('x').map(|(a, b)| (a.parse().unwrap_or(7360), b.parse().unwrap_or(4912)))).unwrap_or((7360, 4912)); + let reps: usize = arg(&args, "--reps").and_then(|v| v.parse().ok()).unwrap_or(5).max(1); + println!("document {w}×{h} ({:.1} MP), median of {reps}", (w * h) as f64 / 1e6); + println!("{:<22} {:>10} {:>14} {:>14} {:>12} {:>10}", "profile", "composite", "CPU before", "CPU after", "per frame", "LUT build"); + for (name, profile, depth) in [ + ("sRGB (untagged)", None, SampleType::U8), + ("Display P3", Some(Builtin::DisplayP3), SampleType::U8), + ("ProPhoto", Some(Builtin::ProPhotoCompat), SampleType::U16), + ("linear sRGB (EXR)", Some(Builtin::LinearSrgb), SampleType::F32), + ] { + let mut d = Document::with_background("b", Size::new(w, h), ColorMode::Rgb, depth, Color::rgb(0.7, 0.4, 0.2)); + d.icc_profile = profile.map(|b| b.profile().to_bytes()); + let s = Session::new(); + let mut buf = None; + let composite = time(reps, || buf = Some(photocraft_compose::flatten(&d))); + let buf = buf.expect("composited"); + let before = time(reps, || { + std::hint::black_box(buf.to_rgba8()); + }); + let display = s.color.canvas_display(&d).expect("display"); + let after = time(reps, || { + let d = s.color.canvas_display(&d).expect("display"); + std::hint::black_box(if d.is_identity() { buf.to_rgba8() } else { d.to_rgba8(&buf) }); + }); + let frame = time(reps * 200, || { + std::hint::black_box(s.color.display_signature(&d)); + }); + let lut = time(reps, || { + std::hint::black_box(s.color.canvas_lut(&d, 33).expect("lut")); + }); + let tag = if display.is_identity() { " (identity)" } else { "" }; + println!("{:<22} {composite:>8.1}ms {before:>12.1}ms {after:>12.1}ms {:>10.1}µs {lut:>8.1}ms{tag}", name, frame * 1000.0); + } +} diff --git a/crates/engine/src/color_cmds.rs b/crates/engine/src/color_cmds.rs index 2c473871bb..e7d6a3e083 100644 --- a/crates/engine/src/color_cmds.rs +++ b/crates/engine/src/color_cmds.rs @@ -123,6 +123,9 @@ pub struct ColorSettings { /// Advanced › "Blend Text Colors Using Gamma" (1 = off; Photoshop's default 1.45): type /// layers mix anti-aliased edges in this gamma (`photocraft_compose::psblend::set_text_gamma`). pub blend_text_gamma: f32, + /// Monitor profile the canvas is displayed in: `auto` (the main display's profile when the + /// platform supplies it, else sRGB), a built-in RGB profile id or an `.icc` path. + pub monitor_profile: String, } impl Default for ColorSettings { @@ -141,6 +144,7 @@ impl Default for ColorSettings { bpc: true, dither: true, blend_text_gamma: photocraft_compose::psblend::TEXT_GAMMA, + monitor_profile: "auto".into(), } } } @@ -176,6 +180,13 @@ pub fn validate_settings(c: &ColorSettings) -> std::result::Result<(), String> { return Err(format!("working space `{spec}` is {:?}, not {space:?}", p.color_space)); } } + let m = c.monitor_profile.as_str(); + if !(m.is_empty() || m == "auto") { + let p = resolve_profile(m, None, Some(ColorMode::Rgb)).map_err(|e| e.to_string())?; + if p.color_space != ColorSpace::Rgb { + return Err(format!("monitor profile `{m}` is {:?}, not RGB", p.color_space)); + } + } if Intent::parse(&c.intent).is_none() { return Err(format!("unknown intent `{}` (perceptual|relative|saturation|absolute)", c.intent)); } @@ -188,9 +199,11 @@ pub struct ColorState { /// Edit › Color Settings (persisted with the preferences). pub settings: ColorSettings, proofs: HashMap, - /// Monitor profile bytes supplied by the platform (`None` = sRGB display). + /// The main display's ICC profile as read by the platform (used when Color Settings › + /// Monitor Profile is `auto`; `None` = sRGB display). pub monitor_profile: Option>>, display_cache: Mutex>>, + pub(crate) display: crate::display_color::DisplayCaches, /// View › 32-bit Preview Options per document. pub hdr: HashMap, } @@ -232,7 +245,10 @@ impl ColorState { Some(emb) => { let mismatch = emb.content_hash() != working.content_hash(); let base = json!({"embedded": emb.description, "working": working.description, "mismatch": mismatch, "policy": policy.id()}); - if !mismatch { + // 32-bit linear images (EXR/HDR are tagged linear sRGB on import) stay linear, as + // Photoshop keeps 32-bit documents in a linear version of the working space. + let linear_hdr = doc.depth == SampleType::F32 && emb.content_hash() == Builtin::LinearSrgb.profile().content_hash(); + if !mismatch || linear_hdr { return merge(base, json!({"action": "kept"})); } let action = match policy { @@ -266,24 +282,20 @@ impl ColorState { self.proofs.get(&doc).cloned().unwrap_or_else(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() }) } + /// The document's proofing state when it was ever changed (no default allocated). + pub(crate) fn proof_ref(&self, doc: DocId) -> Option<&ProofView> { + self.proofs.get(&doc) + } + pub(crate) fn proof_mut(&mut self, doc: DocId) -> &mut ProofView { self.proofs.entry(doc).or_insert_with(|| ProofView { gamut_threshold: photocraft_cms::gamut::DEFAULT_THRESHOLD, ..Default::default() }) } - /// The monitor profile (sRGB when none was supplied or it does not parse as RGB). - pub fn monitor(&self) -> Arc { - self.monitor_profile - .as_ref() - .and_then(|b| profile_from_bytes(b).ok()) - .filter(|p| p.color_space == ColorSpace::Rgb) - .unwrap_or_else(|| Arc::new(Builtin::Srgb.profile().clone())) - } - - /// Transform from the document's composite (what `photocraft_compose::flatten` returns) to + /// Transform from the canvas texture values (the composite, `CanvasDisplay::source`) to /// the monitor, including the soft proof when View › Proof Colors is on. Cached per /// profile pair and proof settings. pub fn display_transform(&self, doc: &Document) -> Result> { - let src = composite_profile(doc); + let src = self.canvas_display(doc)?.source.clone(); let dst = self.monitor(); let pv = self.proof(doc.id); let proof = pv.enabled.then(|| (pv.setup.profile.content_hash(), pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper)); @@ -294,7 +306,7 @@ impl ColorState { let t = if pv.enabled { Transform::proof(&src, &pv.setup.profile, &dst, pv.setup.intent, pv.setup.bpc, pv.setup.simulate_paper) } else { - Transform::new(&src, &dst, Intent::RelativeColorimetric, true) + Transform::new(&src, &dst, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC) } .map_err(cms_err)?; let t = Arc::new(t); @@ -316,18 +328,20 @@ impl ColorState { Ok(Lut3d::from_transform(&t, size)) } - /// What the canvas should do for `doc`: `None` when neither Proof Colors nor Gamut Warning is - /// on, else an RGBA8 display LUT (`size`³, red fastest) whose alpha is 255 where the colour is - /// out of the proof gamut (only with Gamut Warning on). + /// What the canvas should do for `doc`: `None` when the canvas values go to the screen + /// unchanged (the document's display profile matches the monitor and neither Proof Colors, + /// Gamut Warning nor a 32-bit preview is on), else an RGBA8 display LUT (`size`³, red fastest) + /// mapping canvas texture values to the monitor, whose alpha is 255 where the colour is out + /// of the proof gamut (only with Gamut Warning on). pub fn canvas_lut(&self, doc: &Document, size: usize) -> Result>> { let pv = self.proof(doc.id); + let display = self.canvas_display(doc)?; if !pv.enabled && !pv.gamut_warning && !crate::proof_sim::hdr_active(self, doc) { - return Ok(None); + return Ok(display.transform.as_ref().map(|t| Lut3d::from_transform(t, size.max(2)).to_rgba8())); } let lut = self.display_lut(doc, size)?; let mut bytes = lut.to_rgba8(); - let check = - if pv.gamut_warning { Some(GamutCheck::new(&composite_profile(doc), &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None }; + let check = if pv.gamut_warning { Some(GamutCheck::new(&display.source, &pv.setup.profile, pv.gamut_threshold).map_err(cms_err)?) } else { None }; let s = (size - 1) as f32; for (i, px) in bytes.as_chunks_mut::<4>().0.iter_mut().enumerate() { let rgb = [(i % size) as f32 / s, ((i / size) % size) as f32 / s, (i / (size * size)) as f32 / s]; @@ -787,6 +801,9 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { if let Some(v) = str_of("workingGray") { next.working_gray = v; } + if let Some(v) = str_of("monitorProfile") { + next.monitor_profile = v; + } for (k, slot) in [("policyRgb", &mut next.policy_rgb), ("policyCmyk", &mut next.policy_cmyk), ("policyGray", &mut next.policy_gray)] { if let Some(v) = p.get(k).and_then(Value::as_str) { *slot = Policy::parse(v).ok_or_else(|| EngineError::BadParams { cmd: cmd.into(), msg: format!("`{k}` must be preserve|convert|off") })?; @@ -830,6 +847,8 @@ fn color_settings(s: &mut Session, p: &Value) -> Result { Ok(json!({ "settings": c, "working": {"rgb": desc(ColorMode::Rgb), "cmyk": desc(ColorMode::Cmyk), "gray": desc(ColorMode::Grayscale)}, + "monitor": s.color.monitor().description, + "monitorDetected": s.color.monitor_profile.is_some(), })) } @@ -912,7 +931,7 @@ pub fn specs() -> Vec { "edit.colorSettings", "Color Settings…", ["Edit"], - r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"reset":bool=false} (working spaces also accept .icc paths)"##, + r##"{"workingRgb":"srgb|display-p3|adobe-rgb-compat|prophoto-compat|linear-srgb|rec2020","workingCmyk":"coated-cmyk","workingGray":"sgray|gray-gamma-2.2","policyRgb":"preserve|convert|off","policyCmyk":"preserve|convert|off","policyGray":"preserve|convert|off","askOnMismatch":bool=true,"askOnPaste":bool=true,"askOnMissing":bool=false,"intent":"relative|perceptual|saturation|absolute","blendTextGamma":1.0..2.2|bool=1.45,"bpc":bool=true,"dither":bool=true,"monitorProfile":"auto|srgb|display-p3|adobe-rgb-compat|prophoto-compat|rec2020","reset":bool=false} (working spaces and the monitor profile also accept .icc paths; monitor `auto` = the main display's profile when the platform provides it, else sRGB)"##, always, color_settings, true, diff --git a/crates/engine/src/display_color.rs b/crates/engine/src/display_color.rs new file mode 100644 index 0000000000..b77a23a078 --- /dev/null +++ b/crates/engine/src/display_color.rs @@ -0,0 +1,236 @@ +//! Colour-managed canvas display: the document's composite shown through document profile → +//! monitor profile, on the GPU canvas (folded into the display 3D LUT) and the CPU canvas +//! (an 8-bit transform of the composite). +//! +//! * The composite is in [`composite_profile`] (the document profile for RGB, its gray curve as +//! RGB for gray documents, sRGB for CMYK — read through the document's CMYK profile, see +//! `photocraft_color::convert::CmykSpace` — and Lab). +//! * Linear composites (EXR/HDR, linear profiles) are stored in the 8-bit canvas texture +//! sRGB-encoded ([`CanvasDisplay::encode_srgb`]) so shadows keep their precision; the display +//! source profile is then the same primaries with the sRGB curve. +//! * The monitor profile comes from Edit › Color Settings › Monitor Profile: `auto` (the +//! platform's main display profile when the app supplied one, else sRGB), a built-in id or +//! an `.icc` path. +//! * When the source and the monitor match (sRGB documents on an sRGB display, the common +//! case) the transform is the identity and nothing is applied on either path. +//! +//! Results are cached per (document profile, mode, monitor profile); the rendering intent is +//! relative colorimetric with black point compensation, as for Photoshop's monitor display. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; + +use photocraft_cms::{Builtin, ColorSpace, Curve, Intent, Profile, Transform}; +use photocraft_compose::Buffer; +use photocraft_doc::Document; +use photocraft_raster::Rgba8Image; + +use crate::color_cmds::{ColorState, composite_profile, mode_space, profile_from_bytes, resolve_profile}; +use crate::{EngineError, Result}; + +/// Display intent and black point compensation of the monitor transform. +pub const DISPLAY_INTENT: Intent = Intent::RelativeColorimetric; +pub const DISPLAY_BPC: bool = true; + +/// How the canvas shows one document. +#[derive(Debug)] +pub struct CanvasDisplay { + /// The canvas texture stores `srgb_encode(value)` instead of the composite value (linear + /// composites; see the module docs). + pub encode_srgb: bool, + /// Profile of the canvas texture values (the composite profile, or its sRGB-curve twin + /// when `encode_srgb`). + pub source: Arc, + /// The monitor profile. + pub monitor: Arc, + /// Texture values → monitor; `None` when that is the identity (within half an 8-bit step). + pub transform: Option>, + /// Changes whenever any of the above changes (for UI caches). + pub key: u64, +} + +impl CanvasDisplay { + /// Nothing to do: values go to the screen unchanged. + pub fn is_identity(&self) -> bool { + !self.encode_srgb && self.transform.is_none() + } + + /// CPU canvas: a straight-alpha composite → RGBA8 monitor values. + pub fn to_rgba8(&self, buf: &Buffer) -> Rgba8Image { + let mut img = if self.encode_srgb { encode_rgba8(buf) } else { buf.to_rgba8() }; + if let Some(t) = &self.transform { + apply_u8(t, &mut img.pixels); + } + img + } + + /// GPU canvas: a CPU composite as the texture stores it (sRGB-encoded for linear + /// composites; the display LUT does the rest). + pub fn texture_buffer<'a>(&self, buf: &'a Buffer) -> Cow<'a, Buffer> { + if !self.encode_srgb { + return Cow::Borrowed(buf); + } + let mut b = buf.clone(); + for p in &mut b.px { + for v in &mut p[..3] { + *v = photocraft_color::convert::linear_to_srgb(v.clamp(0.0, 1.0)); + } + } + Cow::Owned(b) + } +} + +/// Linear → sRGB-encoded 8-bit codes, indexed by the linear value in 1/65535 steps. +fn encode_table() -> &'static [u8] { + static T: OnceLock> = OnceLock::new(); + T.get_or_init(|| (0..=65535u32).map(|i| (photocraft_color::convert::linear_to_srgb(i as f32 / 65535.0) * 255.0 + 0.5) as u8).collect()) +} + +fn encode_rgba8(buf: &Buffer) -> Rgba8Image { + let t = encode_table(); + let mut img = Rgba8Image::new(buf.rect.width(), buf.rect.height()); + let code = |v: f32| t.get((v.clamp(0.0, 1.0) * 65535.0 + 0.5) as usize).copied().unwrap_or(255); + for (o, p) in img.pixels.as_chunks_mut::<4>().0.iter_mut().zip(&buf.px) { + *o = [code(p[0]), code(p[1]), code(p[2]), (p[3].clamp(0.0, 1.0) * 255.0 + 0.5) as u8]; + } + img +} + +/// In-place 8-bit RGBA transform (alpha kept), in blocks so the scratch copy stays small. +fn apply_u8(t: &Transform, px: &mut [u8]) { + const BLOCK: usize = 1 << 20; + let mut src = Vec::new(); + for chunk in px.chunks_mut(BLOCK * 4) { + src.clear(); + src.extend_from_slice(chunk); + t.convert_u8(&src, 4, chunk, 4, true); + } +} + +/// Is `p` an RGB matrix/TRC profile with linear curves? +fn is_linear_rgb(p: &Profile) -> bool { + p.color_space == ColorSpace::Rgb && p.is_matrix_shaper() && p.trc.as_ref().is_some_and(|t| t.iter().all(Curve::is_identity)) +} + +/// The same primaries with the sRGB curve (what an sRGB-encoded texture of a linear composite is in). +fn srgb_curve_twin(p: &Profile) -> Profile { + let c = photocraft_cms::curve::srgb_trc(); + let mut q = p.clone(); + q.trc = Some([c.clone(), c.clone(), c]); + q.description = format!("{} (sRGB-encoded)", p.description); + q.with_encoded_bytes() +} + +/// Does `t` map every lattice point (the neutral axis only for gray documents) to itself +/// within half an 8-bit step? +fn is_identity(t: &Transform, neutral_only: bool) -> bool { + const N: usize = 9; + let s = (N - 1) as f32; + let mut out = [0.0f32; 16]; + let mut check = |v: [f32; 3]| { + t.eval(&v, &mut out); + (0..3).all(|i| (out[i] - v[i]).abs() <= 0.5 / 255.0) + }; + if neutral_only { + return (0..=32).all(|i| check([i as f32 / 32.0; 3])); + } + (0..N).all(|b| (0..N).all(|g| (0..N).all(|r| check([r as f32 / s, g as f32 / s, b as f32 / s])))) +} + +fn hash_of(v: impl std::hash::Hash) -> u64 { + use std::hash::Hasher; + let mut h = std::collections::hash_map::DefaultHasher::new(); + v.hash(&mut h); + h.finish() +} + +/// The resolved monitor profile and what it was resolved from (setting, platform bytes). +type MonitorCache = Option<(String, Option>>, Arc)>; + +/// Caches of [`ColorState`] for the display (monitor profile and per-document displays). +#[derive(Default)] +pub struct DisplayCaches { + monitor: Mutex, + canvas: Mutex>>, +} + +impl ColorState { + /// The monitor profile: Color Settings › Monitor Profile (`auto`: the platform's profile when + /// supplied in [`ColorState::monitor_profile`], else sRGB). Non-RGB or unreadable profiles + /// fall back to sRGB. + pub fn monitor(&self) -> Arc { + let spec = self.settings.monitor_profile.as_str(); + let mut cache = self.display.monitor.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((s, b, p)) = cache.as_ref() + && s == spec + && match (b, &self.monitor_profile) { + (None, None) => true, + (Some(a), Some(b)) => Arc::ptr_eq(a, b), + _ => false, + } + { + return p.clone(); + } + let p = if spec.is_empty() || spec == "auto" { + self.monitor_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()) + } else { + resolve_profile(spec, None, Some(photocraft_color::ColorMode::Rgb)).ok() + } + .filter(|p| p.color_space == ColorSpace::Rgb) + .unwrap_or_else(|| Arc::new(Builtin::Srgb.profile().clone())); + *cache = Some((spec.to_string(), self.monitor_profile.clone(), p.clone())); + p + } + + /// How the canvas shows `doc` (cached per document profile, mode and monitor profile). + pub fn canvas_display(&self, doc: &Document) -> Result> { + let space = mode_space(doc.mode); + let doc_hash = doc.icc_profile.as_ref().and_then(|b| profile_from_bytes(b).ok()).filter(|p| p.color_space == space).map_or(0, |p| p.content_hash()); + let monitor = self.monitor(); + let key = (space, doc_hash, monitor.content_hash()); + if let Some(d) = self.display.canvas.lock().unwrap_or_else(|e| e.into_inner()).get(&key) { + return Ok(d.clone()); + } + let composite = composite_profile(doc); + let encode_srgb = is_linear_rgb(&composite); + let source = if encode_srgb { Arc::new(srgb_curve_twin(&composite)) } else { composite }; + let transform = if source.content_hash() == monitor.content_hash() { + None + } else { + let t = Transform::new(&source, &monitor, DISPLAY_INTENT, DISPLAY_BPC).map_err(|e| EngineError::Other(format!("colour management: {e}")))?; + (!is_identity(&t, space == ColorSpace::Gray)).then(|| Arc::new(t)) + }; + let d = Arc::new(CanvasDisplay { + encode_srgb, + key: hash_of((source.content_hash(), monitor.content_hash(), encode_srgb, transform.is_some())), + source, + monitor, + transform, + }); + let mut c = self.display.canvas.lock().unwrap_or_else(|e| e.into_inner()); + if c.len() > 32 { + c.clear(); + } + c.insert(key, d.clone()); + Ok(d) + } + + /// Changes whenever the canvas display of `doc` changes: profiles, monitor, Proof Colors, + /// Gamut Warning and 32-bit preview settings (for the UI's LUT cache). + pub fn display_signature(&self, doc: &Document) -> u64 { + let cm = self.canvas_display(doc).map(|d| d.key).unwrap_or(0); + // Per frame: read the proof state in place (the default state allocates a profile). + let proof = self.proof_ref(doc.id).filter(|pv| pv.enabled || pv.gamut_warning).map(|pv| { + let s = &pv.setup; + (pv.enabled, pv.gamut_warning, s.profile.content_hash(), s.intent, s.bpc, s.simulate_paper, s.kind, pv.gamut_threshold.to_bits()) + }); + let hdr = + crate::proof_sim::hdr_active(self, doc).then(|| self.hdr.get(&doc.id).map(|h| (h.highlight_compression, h.exposure.to_bits(), h.gamma.to_bits()))); + hash_of((cm, proof, hdr)) + } +} + +#[cfg(test)] +#[path = "display_color_tests.rs"] +mod tests; diff --git a/crates/engine/src/display_color_tests.rs b/crates/engine/src/display_color_tests.rs new file mode 100644 index 0000000000..9785faa013 --- /dev/null +++ b/crates/engine/src/display_color_tests.rs @@ -0,0 +1,251 @@ +//! Colour-managed display (#46): non-sRGB documents show their colours on an sRGB (or other) +//! monitor. Fixtures are built-in (CC0) or synthesized profiles only. + +use std::sync::Arc; + +use photocraft_cms::synth::{CmykParams, cmyk_profile}; +use photocraft_cms::{Builtin, Intent, Profile, Transform}; +use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; +use photocraft_doc::{Document, Layer, LayerContent, Size}; +use photocraft_geom::Rect; +use photocraft_raster::Surface; +use serde_json::json; + +use crate::Session; + +/// Tolerance of the CPU path against the reference (8-bit quantisation + table evaluation). +const TOL: f32 = 2.0; + +fn rgb_doc(profile: Option<&Profile>, rgb: [f32; 3], depth: SampleType) -> Document { + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Rgb, depth, Color::rgb(rgb[0], rgb[1], rgb[2])); + d.icc_profile = profile.map(Profile::to_bytes); + d +} + +fn gray_doc(profile: &Profile, v: f32) -> Document { + let mut d = Document::with_background("t", Size::new(8, 8), ColorMode::Grayscale, SampleType::U8, Color::gray(v)); + d.icc_profile = Some(profile.to_bytes()); + d +} + +/// A CMYK document of two layers (so composites go through the compositor): the ink below and +/// an empty layer on top. +fn cmyk_doc(profile: Option<&Profile>, ink: [f32; 4]) -> Document { + let fmt = PixelFormat::new(ColorMode::Cmyk, SampleType::U8, true); + let mut d = Document::new("t", Size::new(8, 8), ColorMode::Cmyk, SampleType::U8); + let mut s = Surface::new(fmt); + s.fill_rect(Rect::new(0, 0, 8, 8), &[ink[0], ink[1], ink[2], ink[3], 1.0]); + d.layers.push(Layer::new("ink", LayerContent::Raster(s))); + d.layers.push(Layer::new("empty", LayerContent::Raster(Surface::new(fmt)))); + d.icc_profile = profile.map(Profile::to_bytes); + d +} + +/// Synthetic CMYK profile unlike the built-in coated one (heavier dot gain, small tables). +fn test_cmyk() -> Profile { + cmyk_profile(&CmykParams { description: "Test Uncoated CMYK".into(), tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 7, grid_b2a: 11, ..Default::default() }) +} + +/// What the CPU canvas shows at the centre pixel (RGBA8). +fn shown(s: &Session, d: &Document) -> [f32; 3] { + let buf = photocraft_compose::flatten(d); + let img = s.color.canvas_display(d).unwrap().to_rgba8(&buf); + let i = (4 * d.size.width as usize + 4) * 4; + [0, 1, 2].map(|k| img.pixels[i + k] as f32) +} + +/// Reference: the CMS evaluating document values → sRGB, in 8-bit codes. +fn reference(src: &Profile, v: &[f32]) -> [f32; 3] { + let t = Transform::new(src, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap(); + let mut o = [0.0f32; 16]; + t.eval(v, &mut o); + let n = t.outputs(); + [0, 1, 2].map(|k| o[if n == 1 { 0 } else { k }].clamp(0.0, 1.0) * 255.0) +} + +fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() <= tol) +} + +fn far(a: [f32; 3], b: [f32; 3], d: f32) -> bool { + (0..3).any(|i| (a[i] - b[i]).abs() >= d) +} + +#[test] +fn srgb_is_the_identity_fast_path() { + let s = Session::new(); + for d in [rgb_doc(None, [0.8, 0.5, 0.3], SampleType::U8), rgb_doc(Some(Builtin::Srgb.profile()), [0.8, 0.5, 0.3], SampleType::U16)] { + let cd = s.color.canvas_display(&d).unwrap(); + assert!(cd.is_identity(), "sRGB on an sRGB monitor costs nothing"); + assert!(s.color.canvas_lut(&d, 9).unwrap().is_none(), "no GPU LUT either"); + assert!(close(shown(&s, &d), [204.0, 127.5, 76.5], 0.6)); + } + // Untagged gray (sGray), CMYK (shown in sRGB) and Lab composites are sRGB too. + let g = Document::with_background("g", Size::new(4, 4), ColorMode::Grayscale, SampleType::U8, Color::gray(0.3)); + assert!(s.color.canvas_display(&g).unwrap().is_identity()); + assert!(s.color.canvas_display(&cmyk_doc(None, [0.1, 0.5, 0.5, 0.0])).unwrap().is_identity()); + // Cached: the same Arc for the same profiles. + let d = rgb_doc(Some(Builtin::DisplayP3.profile()), [0.5; 3], SampleType::U8); + assert!(Arc::ptr_eq(&s.color.canvas_display(&d).unwrap(), &s.color.canvas_display(&d).unwrap())); +} + +#[test] +fn display_p3_matches_hand_computed_srgb() { + // P3 (0.8, 0.5, 0.3): linear (0.6038, 0.2140, 0.0732) → linear sRGB through the published + // P3 → sRGB matrix (0.6915, 0.1976, 0.0517) → encoded (216.7, 122.9, 64.3). + let s = Session::new(); + let d = rgb_doc(Some(Builtin::DisplayP3.profile()), [0.8, 0.5, 0.3], SampleType::U8); + let got = shown(&s, &d); + assert!(close(got, [216.7, 122.9, 64.3], TOL), "{got:?}"); + assert!(far(got, [204.0, 127.5, 76.5], 10.0), "raw values would be desaturated: {got:?}"); + // The GPU LUT maps the same value there (the lattice point nearest to it, within interpolation error). + let lut = s.color.canvas_lut(&d, 33).expect("lut").expect("not the identity"); + assert_eq!(lut.len(), 33 * 33 * 33 * 4); +} + +#[test] +fn wide_gamut_and_gray_fixtures_display_correctly() { + let s = Session::new(); + let cases: [(&Profile, [f32; 3]); 3] = [ + (Builtin::AdobeRgbCompat.profile(), [0.3, 0.7, 0.4]), + (Builtin::ProPhotoCompat.profile(), [0.55, 0.35, 0.25]), + (Builtin::DisplayP3.profile(), [0.2, 0.6, 0.8]), + ]; + for (p, v) in cases { + for depth in [SampleType::U8, SampleType::U16, SampleType::F32] { + let d = rgb_doc(Some(p), v, depth); + let got = shown(&s, &d); + let want = reference(p, &v); + assert!(close(got, want, TOL), "{} {depth:?}: {got:?} vs {want:?}", p.description); + assert!(far(got, v.map(|x| x * 255.0), 4.0), "{}: shown raw {got:?}", p.description); + } + } + // Gray Gamma 2.2: gray 0.1 is L = 0.1^2.2 = 0.00631 → sRGB 18.6 (raw would be 25.5). + let d = gray_doc(Builtin::GrayGamma22.profile(), 0.1); + assert!(!s.color.canvas_display(&d).unwrap().is_identity()); + let got = shown(&s, &d); + assert!(close(got, [18.6; 3], TOL), "{got:?}"); +} + +#[test] +fn embedded_cmyk_profile_is_used_for_display_and_flat_export() { + let s = Session::new(); + let p = test_cmyk(); + let ink = [0.1, 0.6, 0.7, 0.05]; + let want = reference(&p, &ink); + let coated = reference(Builtin::CoatedCmyk.profile(), &ink); + assert!(far(want, coated, 4.0), "the fixture differs from the default: {want:?} {coated:?}"); + let d = cmyk_doc(Some(&p), ink); + let got = shown(&s, &d); + assert!(close(got, want, TOL), "canvas {got:?} vs {want:?} (built-in would be {coated:?})"); + // Untagged documents keep the built-in coated CMYK. + assert!(close(shown(&s, &cmyk_doc(None, ink)), coated, TOL)); + // Flat export of the (layered) document writes sRGB through the embedded profile. + let r = photocraft_io::export(&d, "out.png", &photocraft_io::ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + let px = &img.data()[(4 * 8 + 4) * 4..][..3]; + assert!(close([px[0] as f32, px[1] as f32, px[2] as f32], want, TOL), "export {px:?} vs {want:?}"); + // A single-layer CMYK document exported to a format without CMYK converts the same way. + let mut one = d.clone(); + one.layers.truncate(1); + let r = photocraft_io::export(&one, "out.png", &photocraft_io::ExportOptions::default()).unwrap(); + let img = photocraft_codecs::decode(&r.bytes).unwrap().convert(photocraft_codecs::ChannelLayout::Rgba, photocraft_codecs::SampleType::U8); + let px = &img.data()[(4 * 8 + 4) * 4..][..3]; + assert!(close([px[0] as f32, px[1] as f32, px[2] as f32], want, TOL), "single-layer export {px:?} vs {want:?}"); +} + +#[test] +fn linear_documents_are_encoded_for_the_texture() { + let s = Session::new(); + // Linear 0.2 is sRGB 0.4845 (123.5). + let d = rgb_doc(Some(Builtin::LinearSrgb.profile()), [0.2, 0.2, 0.2], SampleType::F32); + let cd = s.color.canvas_display(&d).unwrap(); + assert!(cd.encode_srgb && cd.transform.is_none(), "linear sRGB = sRGB primaries: encoding is all it takes"); + assert!(close(shown(&s, &d), [123.5; 3], 1.0), "{:?}", shown(&s, &d)); + let buf = photocraft_compose::flatten(&d); + let tex = cd.texture_buffer(&buf); + assert!((tex.px[0][0] - 0.4845).abs() < 1e-3); + // An sRGB document is passed through untouched. + let srgb = rgb_doc(None, [0.2; 3], SampleType::F32); + let b2 = photocraft_compose::flatten(&srgb); + assert!(matches!(s.color.canvas_display(&srgb).unwrap().texture_buffer(&b2), std::borrow::Cow::Borrowed(_))); +} + +#[test] +fn exr_round_trip_is_linear() { + let mut s = Session::new(); + // An sRGB document exported to EXR is linearised; re-opened it is tagged linear sRGB and + // displays as before. + let d = rgb_doc(None, [0.5, 0.25, 0.75], SampleType::F32); + let r = photocraft_io::export(&d, "x.exr", &photocraft_io::ExportOptions::default()).unwrap(); + let back = photocraft_io::import("x.exr", &r.bytes).unwrap().document; + let icc = back.icc_profile.clone().expect("tagged"); + assert_eq!(Profile::parse(&icc).unwrap().content_hash(), Builtin::LinearSrgb.profile().content_hash()); + let v = back.layers[0].surface().unwrap().pixel(1, 1); + assert!((v[0] - 0.214).abs() < 2e-3, "stored linear: {v:?}"); + assert!(close(shown(&s, &back), shown(&s, &d), 1.0)); + // Opening keeps it linear without a mismatch prompt. + let (_, report) = s.open_document(back, None); + assert_eq!(report["action"], "kept"); + assert!(report.get("ask").is_none()); +} + +#[test] +fn monitor_profile_setting() { + let mut s = Session::new(); + let srgb = rgb_doc(None, [0.8, 0.5, 0.3], SampleType::U8); + let p3 = rgb_doc(Some(Builtin::DisplayP3.profile()), [0.8, 0.5, 0.3], SampleType::U8); + let r = s.execute("edit.colorSettings", json!({"monitorProfile": "display-p3"})).unwrap(); + assert_eq!(r["monitor"], "Display P3"); + // On a P3 monitor, P3 documents are the identity and sRGB ones are converted. + assert!(s.color.canvas_display(&p3).unwrap().is_identity()); + assert!(!s.color.canvas_display(&srgb).unwrap().is_identity()); + let got = shown(&s, &srgb); + let mut want = [0.0f32; 3]; + Transform::new(Builtin::Srgb.profile(), Builtin::DisplayP3.profile(), Intent::RelativeColorimetric, true).unwrap().eval(&[0.8, 0.5, 0.3], &mut want); + assert!(close(got, want.map(|v| v * 255.0), TOL), "{got:?} vs {want:?}"); + // `auto` uses the platform's profile when supplied, else sRGB. + s.execute("edit.colorSettings", json!({"monitorProfile": "auto"})).unwrap(); + assert!(s.color.canvas_display(&srgb).unwrap().is_identity()); + s.color.monitor_profile = Some(Builtin::DisplayP3.profile().to_bytes()); + assert_eq!(s.color.monitor().description, "Display P3"); + assert!(s.color.canvas_display(&p3).unwrap().is_identity()); + // Garbage platform bytes fall back to sRGB. + s.color.monitor_profile = Some(Arc::new(vec![0u8; 16])); + assert_eq!(s.color.monitor().content_hash(), Builtin::Srgb.profile().content_hash()); + // The signature follows the monitor. + let a = s.color.display_signature(&p3); + s.execute("edit.colorSettings", json!({"monitorProfile": "display-p3"})).unwrap(); + assert_ne!(a, s.color.display_signature(&p3)); +} + +#[test] +fn monitor_profile_bad_params_fail_gracefully() { + let mut s = Session::new(); + for bad in [ + json!({"monitorProfile": "coated-cmyk"}), + json!({"monitorProfile": "sgray"}), + json!({"monitorProfile": "no-such-profile"}), + json!({"monitorProfile": "/nonexistent/x.icc"}), + ] { + assert!(s.execute("edit.colorSettings", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(s.color.settings.monitor_profile, "auto", "unchanged after errors"); + // Non-string values are ignored. + assert!(s.execute("edit.colorSettings", json!({"monitorProfile": 7})).is_ok()); +} + +#[test] +fn proof_colors_still_apply_on_top() { + let mut s = Session::new(); + s.execute("file.new", json!({"width": 8, "height": 8, "mode": "rgb", "depth": 8})).unwrap(); + s.execute("edit.assignProfile", json!({"profile": "display-p3"})).unwrap(); + let d = s.active().unwrap().doc.clone(); + let plain = s.color.canvas_lut(&d, 9).unwrap().unwrap(); + s.execute("view.proofColors", json!({"on": true})).unwrap(); + let proof = s.color.canvas_lut(&d, 9).unwrap().unwrap(); + assert_ne!(plain, proof, "the proof changes the LUT"); + let sig = s.color.display_signature(&d); + s.execute("view.gamutWarning", json!({"on": true})).unwrap(); + assert_ne!(sig, s.color.display_signature(&d)); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 510f296823..1604dd28da 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -18,6 +18,7 @@ pub mod channel_cmds; pub mod color_cmds; pub mod commands; pub mod comps_cmds; +pub mod display_color; pub mod distort_cmds; pub mod edit_cmds; pub mod edit_menu_cmds; diff --git a/crates/engine/src/proof_sim.rs b/crates/engine/src/proof_sim.rs index 4b2a9f0982..6f13c0a5b4 100644 --- a/crates/engine/src/proof_sim.rs +++ b/crates/engine/src/proof_sim.rs @@ -22,12 +22,12 @@ use photocraft_doc::Document; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use crate::color_cmds::{ColorState, composite_profile}; +use crate::color_cmds::ColorState; use crate::commands::CommandSpec; use crate::{EngineError, Result, Session}; /// What a proof simulates. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum ProofKind { /// The proof profile (Working CMYK, Custom…). @@ -153,7 +153,7 @@ pub fn display_lut(c: &ColorState, doc: &Document, size: usize) -> Result