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 <noreply@anthropic.com>

* EXR/HDR are linear: tag imports linear sRGB, linearise exports

Refs #46

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 15:44:13 -04:00 committed by GitHub
parent 5bd3dac660
commit d5eb44e9fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1176 additions and 116 deletions

View File

@ -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()

View File

@ -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<Option<Vec<u8>>> {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(detect());
});
rx
}
#[cfg(target_os = "macos")]
fn detect() -> Option<Vec<u8>> {
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<Vec<u8>> {
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<Vec<u8>> {
let val = |c: u8| -> Option<u32> {
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);
}
}
}

View File

@ -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<Vec<u8>>>) -> Option<Arc<CmykSpace>> {
type Cache = Mutex<Vec<(Weak<Vec<u8>>, Option<Arc<CmykSpace>>)>>;
static CACHE: OnceLock<Cache> = 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<Arc<CmykSpace>> {
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<Option<Arc<CmykSpace>>> = const { RefCell::new(None) };
}
/// Restores the previously active CMYK space when dropped (also on unwind).
struct Restore(Option<Arc<CmykSpace>>);
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<R>(space: Option<&Arc<CmykSpace>>, 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<Arc<CmykSpace>> {
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(&params);
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]);

View File

@ -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<Buffer> = {
@ -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<std::sync::Arc<photocraft_color::convert::CmykSpace>> {
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())

View File

@ -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<String> {
args.iter().position(|a| a == name).and_then(|i| args.get(i + 1).cloned())
}
fn median(mut v: Vec<f64>) -> 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<String> = 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);
}
}

View File

@ -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<DocId, ProofView>,
/// 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<Arc<Vec<u8>>>,
display_cache: Mutex<HashMap<DisplayKey, Arc<Transform>>>,
pub(crate) display: crate::display_color::DisplayCaches,
/// View › 32-bit Preview Options per document.
pub hdr: HashMap<DocId, crate::proof_sim::HdrPreview>,
}
@ -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<Profile> {
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<Arc<Transform>> {
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<Option<Vec<u8>>> {
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<Value> {
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<Value> {
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<CommandSpec> {
"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,

View File

@ -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<Profile>,
/// The monitor profile.
pub monitor: Arc<Profile>,
/// Texture values → monitor; `None` when that is the identity (within half an 8-bit step).
pub transform: Option<Arc<Transform>>,
/// 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<Vec<u8>> = 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<Vec<u8>>>, Arc<Profile>)>;
/// Caches of [`ColorState`] for the display (monitor profile and per-document displays).
#[derive(Default)]
pub struct DisplayCaches {
monitor: Mutex<MonitorCache>,
canvas: Mutex<HashMap<(ColorSpace, u64, u64), Arc<CanvasDisplay>>>,
}
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<Profile> {
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<Arc<CanvasDisplay>> {
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;

View File

@ -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));
}

View File

@ -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;

View File

@ -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<Option
return Ok(None);
}
let err = |e: photocraft_cms::CmsError| EngineError::Other(format!("colour management: {e}"));
let src = composite_profile(doc);
let src = c.canvas_display(doc)?.source.clone();
let mon = c.monitor();
let srgb = Builtin::Srgb.profile();
let setup = &pv.setup;
@ -163,7 +163,7 @@ pub fn display_lut(c: &ColorState, doc: &Document, size: usize) -> Result<Option
let t = if pv.enabled {
Transform::proof(&src, &setup.profile, &mon, setup.intent, setup.bpc, setup.simulate_paper)
} else {
Transform::new(&src, &mon, Intent::RelativeColorimetric, true)
Transform::new(&src, &mon, crate::display_color::DISPLAY_INTENT, crate::display_color::DISPLAY_BPC)
}
.map_err(err)?;
Box::new(move |v| eval3(&t, &v))

View File

@ -137,6 +137,8 @@ struct Resident {
default_nonzero: bool,
doc: DocId,
last_used: u64,
/// CMYK profile the texels were converted with (`CmykSpace::id`, 0 = built-in / not CMYK).
cmyk: u64,
}
/// A texture and its default view.
@ -348,6 +350,8 @@ pub struct Compositor {
/// Whether the effect-map pipelines could be built (else documents with layer effects are
/// [`Unsupported`] and use the CPU compositor).
effect_maps: bool,
/// `CmykSpace::id` of the document being encoded (0: built-in coated CMYK).
cmyk: u64,
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
@ -488,6 +492,7 @@ impl Compositor {
frame: 0,
acc_format,
effect_maps: map_error.is_none(),
cmyk: 0,
}
}
@ -565,6 +570,21 @@ impl Compositor {
doc: &Document,
region: Rect,
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
) -> Result<Stats, Unsupported> {
// CMYK layers convert through the document's own CMYK profile (uploads and plan colours).
let space = photocraft_compose::cmyk_space(doc);
self.cmyk = space.as_ref().map_or(0, |s| s.id);
photocraft_color::convert::with_cmyk_space(space.as_ref(), || self.encode_scoped(device, queue, encoder, doc, region, sink))
}
fn encode_scoped(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
doc: &Document,
region: Rect,
sink: &mut dyn FnMut(&mut wgpu::CommandEncoder, ChunkOut<'_>),
) -> Result<Stats, Unsupported> {
let canvas = doc.bounds();
if canvas.width() > self.max_dim || canvas.height() > self.max_dim {
@ -816,7 +836,8 @@ impl Compositor {
let format = surface.format();
let kind = TexKind::for_surface(role, format);
let key = (layer, role);
let stale = self.residents.get(&key).is_none_or(|r| r.region != region || r.kind != kind || r.format != format);
let cmyk = if format.mode == photocraft_color::ColorMode::Cmyk { self.cmyk } else { 0 };
let stale = self.residents.get(&key).is_none_or(|r| r.region != region || r.kind != kind || r.format != format || r.cmyk != cmyk);
if stale {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("pc_compose_layer"),
@ -830,7 +851,7 @@ impl Compositor {
});
let view = texture.create_view(&Default::default());
let default_nonzero = surface.default_pixel().iter().any(|v| *v != 0.0);
let r = Resident { texture, view, region, kind, format, tiles: HashMap::new(), default_nonzero, doc, last_used: 0 };
let r = Resident { texture, view, region, kind, format, tiles: HashMap::new(), default_nonzero, doc, last_used: 0, cmyk };
if default_nonzero {
// Missing tiles read as the default pixel: initialise them.
let bytes = convert_tile(surface, None, kind, TileCoord::new(0, 0));

View File

@ -13,7 +13,14 @@ use crate::{ExportOptions, ExportResult, ImportResult, IoError};
/// Decodes a flat image into a single-layer document.
pub fn import_flat(name: &str, bytes: &[u8]) -> Result<ImportResult, IoError> {
let img = codecs::decode(bytes)?;
image_to_document(name, &img)
let mut r = image_to_document(name, &img)?;
// OpenEXR and Radiance HDR hold linear, scene-referred values (Rec. 709 primaries unless
// stated otherwise): tag them linear sRGB so they display and convert correctly.
let d = &mut r.document;
if d.icc_profile.is_none() && d.mode == ColorMode::Rgb && matches!(codecs::detect(bytes), Some(Format::OpenExr | Format::Hdr)) {
d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes());
}
Ok(r)
}
/// A decoded flat image as a single-layer document.
@ -178,6 +185,9 @@ pub fn export_flat(doc: &Document, format: Format, opts: &ExportOptions) -> Resu
img = cmyk_image_to_srgb(&img)?;
warnings.push(format!("CMYK converted to sRGB for {format:?} through the document's colour profile"));
}
if matches!(format, Format::OpenExr | Format::Hdr) {
img = rgb_image_to_linear(img)?;
}
for w in codecs::fidelity_warnings_with(&img, format, &opts.encode) {
if w.is_fatal() {
return Err(IoError::Unsupported(w.to_string()));
@ -214,6 +224,27 @@ fn cmyk_image_to_srgb(img: &Image) -> Result<Image, IoError> {
Ok(Image::from_normalized(w, h, layout, sample, &out)?.with_icc(Some(dst.to_bytes().to_vec())).with_meta(img.meta.clone()))
}
/// OpenEXR and Radiance HDR store linear light (read back as linear sRGB, see [`import_flat`]):
/// RGB pixels in another profile (sRGB when untagged) are converted to linear sRGB, unclamped.
fn rgb_image_to_linear(img: Image) -> Result<Image, IoError> {
use photocraft_cms::{Builtin, ColorSpace, Intent, Profile, Transform};
if !matches!(img.layout(), ChannelLayout::Rgb | ChannelLayout::Rgba) {
return Ok(img);
}
let linear = Builtin::LinearSrgb.profile();
let src =
img.icc.as_ref().and_then(|b| Profile::parse(b).ok()).filter(|p| p.color_space == ColorSpace::Rgb).unwrap_or_else(|| Builtin::Srgb.profile().clone());
if src.content_hash() == linear.content_hash() {
return Ok(img);
}
let t = Transform::new(&src, linear, Intent::RelativeColorimetric, false).map_err(|e| IoError::Unsupported(e.to_string()))?;
let stride = img.layout().channels();
let mut vals = img.to_normalized();
t.apply(&mut vals, stride);
let (w, h) = img.dimensions();
Ok(Image::from_normalized(w, h, img.layout(), CSample::F32, &vals)?.with_icc(None).with_meta(img.meta.clone()))
}
/// Indexed Color → PNG-8 with its colour table; Duotone → the inks rendered as RGB.
fn export_mode_specific(doc: &Document, format: Format, opts: &ExportOptions) -> Result<Option<ExportResult>, IoError> {
match doc.mode {

View File

@ -551,20 +551,24 @@ pub fn document_to_psd_with(doc: &Document, opts: &PsdExportOptions) -> (PsdFile
let has_alpha = comp.px.iter().any(|p| q255(p[3]) < 255 || (sample == SampleType::F32 && p[3] < 1.0));
let white = photocraft_raster::from_rgba(&fmt, [1.0, 1.0, 1.0, 1.0]);
let cmyk = ex.cmyk;
// The composite came through the document's CMYK profile: convert back through it too.
let space = photocraft_compose::cmyk_space(doc);
// Converted and encoded in bands on all cores, then joined per plane.
let parts = crate::pixels::par_map(crate::pixels::bands(n), |range| {
let mut planes: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * sample.bytes()); cc + 1];
let mut v = [0.0f32; 5];
for p in &comp.px[range] {
photocraft_raster::from_rgba_into(&fmt, *p, &mut v);
for c in 0..=cc {
// Matte against white like Photoshop (see `pixels::matte`).
let m = if c < cc && has_alpha { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
let x = if cmyk && c < cc { 1.0 - m } else { m };
encode_be(x, sample, &mut planes[c]);
photocraft_color::convert::with_cmyk_space(space.as_ref(), || {
let mut planes: Vec<Vec<u8>> = vec![Vec::with_capacity(range.len() * sample.bytes()); cc + 1];
let mut v = [0.0f32; 5];
for p in &comp.px[range] {
photocraft_raster::from_rgba_into(&fmt, *p, &mut v);
for c in 0..=cc {
// Matte against white like Photoshop (see `pixels::matte`).
let m = if c < cc && has_alpha { crate::pixels::matte(v[c], v[cc], white[c]) } else { v[c] };
let x = if cmyk && c < cc { 1.0 - m } else { m };
encode_be(x, sample, &mut planes[c]);
}
}
}
planes
planes
})
});
let mut color_planes: Vec<Vec<u8>> = vec![Vec::with_capacity(n * sample.bytes()); cc + 1];
for part in parts {

View File

@ -48,7 +48,28 @@ codec_rt!(tiff_rgb16, "tif", ColorMode::Rgb, SampleType::U16, false, 0.0);
codec_rt!(tiff_cmyk8, "tiff", ColorMode::Cmyk, SampleType::U8, false, 0.0);
codec_rt!(tiff_cmyka16, "tiff", ColorMode::Cmyk, SampleType::U16, true, 0.0);
codec_rt!(tiff_gray8, "tiff", ColorMode::Grayscale, SampleType::U8, false, 0.0);
codec_rt!(exr_rgba32, "exr", ColorMode::Rgb, SampleType::F32, true, 0.0);
/// EXR stores linear light: a linear document round-trips exactly; an sRGB (untagged) one is
/// linearised on export and comes back tagged linear sRGB with the same colours.
#[test]
fn exr_rgba32() {
let mut d = single(ColorMode::Rgb, SampleType::F32, true);
d.icc_profile = Some(photocraft_cms::Builtin::LinearSrgb.profile().to_bytes());
let r = export(&d, "exr", &ExportOptions::default()).expect("export");
let back = import("x.exr", &r.bytes).expect("import").document;
assert_eq!((back.size, back.mode, back.depth), (d.size, d.mode, d.depth));
assert_eq!(back.icc_profile.as_deref(), d.icc_profile.as_deref(), "tagged linear sRGB");
pixels_eq(&d, &back, 0.0);
// Untagged (sRGB) → linearised.
let srgb = single(ColorMode::Rgb, SampleType::F32, true);
let r = export(&srgb, "exr", &ExportOptions::default()).expect("export");
let back = import("x.exr", &r.bytes).expect("import").document;
let (a, b) = (srgb.layers[0].surface().unwrap().pixel(2, 3), back.layers[0].surface().unwrap().pixel(2, 3));
for c in 0..3 {
assert!((photocraft_color::convert::srgb_to_linear(a[c]) - b[c]).abs() < 1e-4, "{a:?} -> {b:?}");
}
assert!((a[3] - b[3]).abs() < 1e-6, "alpha kept");
}
fn smooth(mode: ColorMode) -> photocraft_doc::Document {
let mut d = photocraft_doc::Document::new("s", photocraft_geom::Size::new(32, 16), mode, SampleType::U8);

View File

@ -114,6 +114,30 @@ fn buffer_to_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage {
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels)
}
/// A composite as monitor pixels: document profile → monitor profile (nothing to do in the
/// common sRGB-on-sRGB case).
fn display_image(display: Option<&photocraft_engine::display_color::CanvasDisplay>, buf: &photocraft_compose::Buffer) -> egui::ColorImage {
match display {
Some(d) if !d.is_identity() => {
let img = d.to_rgba8(buf);
egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.pixels)
}
_ => buffer_to_image(buf),
}
}
/// The canvas display of `doc` and a key that changes with it (folded into the canvas caches'
/// preview keys, so a monitor or profile change re-renders).
fn canvas_display(app: &PhotocraftApp, doc: &Document) -> (Option<std::sync::Arc<photocraft_engine::display_color::CanvasDisplay>>, u64) {
match app.session.color.canvas_display(doc) {
Ok(d) => {
let k = d.key;
(Some(d), k)
}
Err(_) => (None, 0),
}
}
/// Box-downsample a composite for display.
fn downsample(buf: &photocraft_compose::Buffer, factor: u32) -> photocraft_compose::Buffer {
let (w, h) = (buf.rect.width(), buf.rect.height());
@ -201,6 +225,8 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize)
(st.revision, st.last_damage.map(|r| if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) }), st.doc.id)
};
let (doc, preview_key) = display_doc(app, idx);
let (display, display_key) = canvas_display(app, &doc);
let preview_key = preview_key ^ display_key;
let cache = app.canvases.entry(id).or_insert(CanvasCache {
revision: 0,
texture: None,
@ -223,7 +249,7 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize)
let buf = photocraft_compose::render(&doc, r);
let t1 = crate::gpu_canvas::now_ms();
if let Some(t) = cache.texture.as_mut() {
t.set_partial([r.x0 as usize, r.y0 as usize], buf_image(&buf), TextureOptions::LINEAR);
t.set_partial([r.x0 as usize, r.y0 as usize], display_image(display.as_deref(), &buf), TextureOptions::LINEAR);
}
app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
@ -232,7 +258,8 @@ pub fn ensure_texture(app: &mut PhotocraftApp, ctx: &egui::Context, idx: usize)
let t1 = crate::gpu_canvas::now_ms();
let longest = doc.size.width.max(doc.size.height);
let factor = longest.div_ceil(MAX_TEXTURE).max(1);
let (img, scale) = if factor > 1 { (buffer_to_image(&downsample(&full, factor)), 1.0 / factor as f32) } else { (buffer_to_image(&full), 1.0) };
let d = display.as_deref();
let (img, scale) = if factor > 1 { (display_image(d, &downsample(&full, factor)), 1.0 / factor as f32) } else { (display_image(d, &full), 1.0) };
match cache.texture.as_mut() {
Some(t) if t.size() == img.size => t.set(img, TextureOptions::LINEAR),
_ => cache.texture = Some(ctx.load_texture(format!("canvas-{}", id.0), img, TextureOptions::LINEAR)),
@ -278,6 +305,9 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
return false;
};
let (doc, preview_key) = display_doc(app, idx);
let (display, display_key) = canvas_display(app, &doc);
let preview_key = preview_key ^ display_key;
let encode_srgb = display.as_ref().is_some_and(|d| d.encode_srgb);
let size = [doc.size.width, doc.size.height];
let cache = app.canvases.entry(id).or_insert(CanvasCache {
revision: 0,
@ -298,7 +328,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
// Preferred: the wgpu compositor renders straight into the display texture (damage rect for
// strokes, everything otherwise). Falls back to the CPU compositor below when unsupported.
let region = if partial { last_damage.unwrap_or(doc.bounds()).intersect(&doc.bounds()) } else { doc.bounds() };
match gpu.composite(&doc, region) {
match gpu.composite(&doc, region, encode_srgb) {
Ok(stats) => {
done = true;
let kind = if partial { "gpu-rect" } else { "gpu-full" };
@ -319,7 +349,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
} else {
let buf = photocraft_compose::render(&doc, r);
let t1 = crate::gpu_canvas::now_ms();
done = gpu.upload_buffer_rect(id.0, &buf);
done = gpu.upload_buffer_rect(id.0, &texture_buffer(display.as_deref(), &buf));
app.perf.record("rect", r.width() as u64 * r.height() as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
}
@ -328,7 +358,7 @@ fn ensure_gpu(app: &mut PhotocraftApp, idx: usize) -> bool {
// TODO: render only the visible region at display resolution when zoomed out.
let full = photocraft_compose::flatten(&doc);
let t1 = crate::gpu_canvas::now_ms();
gpu.upload_buffer_full(id.0, &full);
gpu.upload_buffer_full(id.0, &texture_buffer(display.as_deref(), &full));
app.perf.record("full", size[0] as u64 * size[1] as u64, t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
let Some(cache) = app.canvases.get_mut(&id) else { return true };
@ -361,7 +391,8 @@ fn ensure_filter_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u6
if let Some(r) = &result {
let buf = photocraft_compose::flatten(r);
let t1 = crate::gpu_canvas::now_ms();
app.gpu.as_ref()?.upload_buffer_full(key, &buf);
let (display, _) = canvas_display(app, &doc);
app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf));
app.perf.record("filter-preview", r.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1);
}
app.filter_preview = Some(crate::filter_dialog::FilterPreview { doc: doc_id, revision, hash, k, result });
@ -399,15 +430,23 @@ fn ensure_proxy_preview(app: &mut PhotocraftApp, idx: usize) -> Option<(u32, u64
let t0 = crate::gpu_canvas::now_ms();
let buf = photocraft_compose::flatten(&p);
let t1 = crate::gpu_canvas::now_ms();
app.gpu.as_ref()?.upload_buffer_full(key, &buf);
let (display, _) = canvas_display(app, &doc);
app.gpu.as_ref()?.upload_buffer_full(key, &texture_buffer(display.as_deref(), &buf));
app.perf.record("proxy", p.size.area(), t1 - t0, crate::gpu_canvas::now_ms() - t1);
app.proxy_uploaded = Some((doc_id, hash));
}
Some((k, key))
}
fn buf_image(buf: &photocraft_compose::Buffer) -> egui::ColorImage {
buffer_to_image(buf)
/// A CPU composite as the GPU canvas texture stores it (sRGB-encoded for linear documents).
fn texture_buffer<'a>(
display: Option<&photocraft_engine::display_color::CanvasDisplay>,
buf: &'a photocraft_compose::Buffer,
) -> std::borrow::Cow<'a, photocraft_compose::Buffer> {
match display {
Some(d) => d.texture_buffer(buf),
None => std::borrow::Cow::Borrowed(buf),
}
}
/// Tabs + canvas for the active document, or the start screen.
@ -653,43 +692,40 @@ fn start_screen(app: &mut PhotocraftApp, ui: &mut egui::Ui) {
});
}
/// Lattice size of the canvas display LUT (Proof Colors / Gamut Warning).
/// Lattice size of the canvas display LUT (colour management, Proof Colors, Gamut Warning).
const DISPLAY_LUT: usize = 33;
/// Keep the GPU display LUT of `doc` in step with its View › Proof Colors / Gamut Warning state.
/// Returns the canvas `display` mode (0 none, 1 proof, 2 proof + gamut warning).
fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocraft_doc::Document) -> u8 {
let Some(gpu) = app.gpu.as_ref() else { return 0 };
let pv = app.session.color.proof(doc.id);
let mode = if pv.gamut_warning { 2 } else { u8::from(pv.enabled) };
/// Keep the GPU display LUT under `key` (the document's texture or a preview of it) in step with
/// `doc`'s colour management: document → monitor profile, View › Proof Colors / Gamut Warning
/// and 32-bit preview. Returns the canvas `display` mode (0 none — the identity, e.g. sRGB on
/// an sRGB monitor —, 1 LUT, 2 LUT + gamut warning).
fn sync_display_lut(app: &mut PhotocraftApp, ctx: &egui::Context, doc: &photocraft_doc::Document, key: u64) -> u8 {
let Some(gpu) = app.gpu.clone() else { return 0 };
// Rebuild only when anything feeding the LUT changes.
let sig = if mode == 0 {
String::new()
} else {
format!(
"{mode} {} {:?} {} {} {} {:?} {:?}",
pv.setup.profile.content_hash(),
pv.setup.intent,
pv.setup.bpc,
pv.setup.simulate_paper,
pv.gamut_threshold,
doc.mode,
doc.icc_profile.as_ref().map(|p| p.len())
)
};
let key = egui::Id::new(("pc-display-lut", doc.id.0));
if ctx.data(|d| d.get_temp::<String>(key)).as_deref() == Some(sig.as_str()) {
let sig = app.session.color.display_signature(doc);
let id = egui::Id::new(("pc-display-lut", key));
if let Some((s, mode)) = ctx.data(|d| d.get_temp::<(u64, u8)>(id))
&& s == sig
{
return mode;
}
let lut = if mode == 0 { Ok(None) } else { app.session.color.canvas_lut(doc, DISPLAY_LUT) };
match lut {
Ok(bytes) => gpu.set_display_lut(doc.id.0, DISPLAY_LUT as u32, bytes.as_deref()),
Err(e) => {
app.ui.status = format!("Proof Colors: {e}");
gpu.set_display_lut(doc.id.0, DISPLAY_LUT as u32, None);
let gamut = app.session.color.proof(doc.id).gamut_warning;
let mode = match app.session.color.canvas_lut(doc, DISPLAY_LUT) {
Ok(Some(bytes)) => {
gpu.set_display_lut(key, DISPLAY_LUT as u32, Some(&bytes));
if gamut { 2 } else { 1 }
}
}
ctx.data_mut(|d| d.insert_temp(key, sig));
Ok(None) => {
gpu.set_display_lut(key, DISPLAY_LUT as u32, None);
0
}
Err(e) => {
app.ui.status = format!("Color management: {e}");
gpu.set_display_lut(key, DISPLAY_LUT as u32, None);
0
}
};
ctx.data_mut(|d| d.insert_temp(id, (sig, mode)));
mode
}
@ -745,7 +781,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
},
pixel_grid: false,
view_key: egui::Id::new(("pc-canvas-proxy", ctx.viewport_id(), idx)).value(),
display: 0,
display: sync_display_lut(app, &ctx, &doc, key),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else if !flip && ensure_gpu(app, idx) {
@ -763,7 +799,7 @@ pub fn canvas_view(app: &mut PhotocraftApp, ui: &mut egui::Ui, idx: usize, rect:
},
pixel_grid,
view_key: egui::Id::new(("pc-canvas", ctx.viewport_id(), idx)).value(),
display: sync_display_lut(app, &ctx, &doc),
display: sync_display_lut(app, &ctx, &doc, doc.id.0),
};
crate::gpu_canvas::GpuCanvas::paint(&painter, rect, params);
} else {

View File

@ -51,8 +51,8 @@ pub struct ViewParams {
pub pixel_grid: bool,
/// Distinguishes views painted in the same frame (main canvas, extra windows).
pub view_key: u64,
/// Display transform: 0 none, 1 the document's display LUT (Proof Colors), 2 LUT plus the
/// gamut warning (see [`GpuCanvas::set_display_lut`]).
/// Display transform: 0 none, 1 the document's display LUT (document → monitor profile,
/// Proof Colors), 2 LUT plus the gamut warning (see [`GpuCanvas::set_display_lut`]).
pub display: u8,
}
@ -147,9 +147,16 @@ impl GpuCanvas {
}
/// Composite `region` of `doc` with the wgpu compositor straight into its display texture
/// (no CPU pixels, no upload of the composite). Returns `Err` when the document uses features
/// the GPU compositor doesn't cover yet; the caller then falls back to the CPU compositor.
pub fn composite(&self, doc: &photocraft_doc::Document, region: photocraft_geom::Rect) -> Result<photocraft_gpu::Stats, photocraft_gpu::Unsupported> {
/// (no CPU pixels, no upload of the composite). `encode_srgb` stores the sRGB encoding of the
/// composite (linear documents, see `photocraft_engine::display_color`). Returns `Err` when the
/// document uses features the GPU compositor doesn't cover yet; the caller then falls back to
/// the CPU compositor.
pub fn composite(
&self,
doc: &photocraft_doc::Document,
region: photocraft_geom::Rect,
encode_srgb: bool,
) -> Result<photocraft_gpu::Stats, photocraft_gpu::Unsupported> {
let (device, queue) = (&self.rs.device, &self.rs.queue);
let size = [doc.size.width, doc.size.height];
if size[0] == 0 || size[1] == 0 {
@ -195,7 +202,7 @@ impl GpuCanvas {
if r.is_empty() {
continue;
}
let offset = [out.rect.x0 - tx, out.rect.y0 - ty, 0, 0];
let offset = [out.rect.x0 - tx, out.rect.y0 - ty, i32::from(encode_srgb), 0];
let ubuf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("pc_encode"),
contents: &offset.iter().flat_map(|v| v.to_le_bytes()).collect::<Vec<u8>>(),
@ -1028,7 +1035,8 @@ fn fs_tile(in: VOut) -> @location(0) vec4<f32> {
col = textureSampleLevel(tex, samp, (i + 0.5 + f) / size, 0.0);
}
if (view.d.z > 0.5 && col.a > 0.0) {
// Proof Colors / Gamut Warning: the document's display LUT (alpha flags out-of-gamut).
// Colour management, Proof Colors, Gamut Warning: the document's display LUT (alpha flags
// out-of-gamut colours).
let n = f32(textureDimensions(lut).x);
let c = clamp(col.rgb / col.a, vec3(0.0), vec3(1.0));
let l = textureSampleLevel(lut, samp, (c * (n - 1.0) + 0.5) / n, 0.0);
@ -1061,11 +1069,20 @@ fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
return vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, 0.0, 1.0);
}
fn srgb_encode(c: vec3<f32>) -> vec3<f32> {
return select(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3(0.0031308));
}
@fragment
fn fs(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let c = textureLoad(acc, vec2<i32>(floor(pos.xy)) - offset.xy, 0);
let a = clamp(c.a, 0.0, 1.0);
return vec4(clamp(c.rgb, vec3(0.0), vec3(1.0)) * a, a);
var rgb = clamp(c.rgb, vec3(0.0), vec3(1.0));
if (offset.z != 0) {
// Linear composite: store its sRGB encoding (8-bit precision where it matters).
rgb = srgb_encode(rgb);
}
return vec4(rgb * a, a);
}
"#;

View File

@ -0,0 +1,80 @@
//! The canvas is colour-managed (#46): a Display P3 document shows its colours converted to
//! the (sRGB) monitor on the GPU canvas (display LUT) and on the CPU canvas (flipped views draw
//! through the CPU path); sRGB documents are shown unchanged. Skips when no GPU adapter with
//! 32-bit float render targets exists (like `crates/gpu/tests/parity.rs`).
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use photocraft_ui_egui::PhotocraftApp;
use serde_json::json;
/// Renders the UI with one 512² document of `rgb` tagged with `profile`; returns the median
/// colour of a patch at the centre of the canvas, for the GPU and the CPU canvas.
fn render(profile: &str, rgb: [f32; 3]) -> Option<([u8; 3], [u8; 3])> {
let float_targets = Arc::new(AtomicBool::new(false));
let ft = float_targets.clone();
let profile = profile.to_string();
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || {
egui_kittest::Harness::builder().with_size(egui::vec2(1000.0, 700.0)).with_pixels_per_point(1.0).with_max_steps(64).wgpu().build_eframe(move |cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, Default::default());
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Default::default());
if let Some(rs) = cc.wgpu_render_state.as_ref() {
ft.store(photocraft_gpu::Compositor::preferred_acc_format(&rs.adapter) == eframe::wgpu::TextureFormat::Rgba32Float, Ordering::SeqCst);
app.set_wgpu(rs.clone());
}
let hex = format!("#{:02x}{:02x}{:02x}", (rgb[0] * 255.0).round() as u8, (rgb[1] * 255.0).round() as u8, (rgb[2] * 255.0).round() as u8);
app.run("file.new", json!({"width": 512, "height": 512, "mode": "rgb", "depth": 8})).expect("new");
app.run("edit.assignProfile", json!({"profile": profile})).expect("assign");
app.run("edit.fill", json!({"color": hex})).expect("fill");
app
})
}));
let Ok(mut harness) = built else {
eprintln!("skipping: no GPU adapter");
return None;
};
if !float_targets.load(Ordering::SeqCst) {
eprintln!("skipping: adapter can't render Rgba32Float");
return None;
}
let sample = |harness: &mut egui_kittest::Harness<'_, PhotocraftApp>| {
harness.run_steps(6);
let img = harness.render().expect("render");
// The document is fitted into the canvas area: its centre lies on the document.
let (cx, cy) = (img.width() as i32 * 2 / 5, img.height() as i32 / 2);
let mut px: Vec<[u8; 3]> = Vec::new();
for dy in -4..=4 {
for dx in -4..=4 {
let p = img.get_pixel((cx + dx) as u32, (cy + dy) as u32);
px.push([p[0], p[1], p[2]]);
}
}
px.sort();
px[px.len() / 2]
};
let gpu = sample(&mut harness);
harness.state_mut().ui.view.flip_horizontal = true;
let cpu = sample(&mut harness);
eprintln!("gpu {gpu:?} cpu {cpu:?}");
Some((gpu, cpu))
}
fn close(a: [u8; 3], b: [f32; 3], tol: f32) -> bool {
(0..3).all(|i| (a[i] as f32 - b[i]).abs() <= tol)
}
#[test]
fn display_p3_is_converted_on_gpu_and_cpu_canvases() {
// P3 (0.8, 0.5, 0.3) → sRGB (216.7, 122.9, 64.3) (see engine display_color_tests); raw would be (204, 128, 77).
let Some((gpu, cpu)) = render("display-p3", [0.8, 0.5, 0.3]) else { return };
assert!(close(cpu, [216.7, 122.9, 64.3], 2.0), "CPU canvas {cpu:?}");
assert!(close(gpu, [216.7, 122.9, 64.3], 3.0), "GPU canvas {gpu:?}");
}
#[test]
fn srgb_documents_are_unchanged() {
let Some((gpu, cpu)) = render("srgb", [0.8, 0.5, 0.3]) else { return };
assert!(close(cpu, [204.0, 128.0, 77.0], 1.0), "CPU canvas {cpu:?}");
assert!(close(gpu, [204.0, 128.0, 77.0], 1.0), "GPU canvas {gpu:?}");
}

View File

@ -315,7 +315,8 @@ Both backends consume the same plan. This is the only place that encodes Photosh
### 7.3 Color
- Blending happens in document space by default, which is Photoshop-compatible. A per-document "linear light blending" option is also available.
- **Display transform:** compose the ICC transforms with `photocraft-cms`, then bake them into a 3D LUT that the canvas shader's final pass applies (Proof Colors and Gamut Warning work this way today).
- **Display transform** (`engine/src/display_color.rs`): the canvas is always colour-managed, document profile → monitor profile (relative colorimetric + BPC), cached per (document profile, mode, monitor). On the GPU canvas the transform, plus Proof Colors / Gamut Warning / 32-bit preview, is baked into a 33³ 3D LUT the canvas shader's final pass applies; the CPU canvas runs an 8-bit `photocraft-cms` transform on the composite. When the document profile matches the monitor (sRGB on sRGB) there is no LUT and no transform. Linear composites (EXR/HDR, tagged linear sRGB on import) are stored sRGB-encoded in the 8-bit canvas texture. CMYK documents are read through their embedded CMYK profile (`photocraft_color::convert::with_cmyk_space`, entered by the compositors and composite exports).
- **Monitor profile:** Edit › Color Settings › Monitor Profile: `auto` (macOS: the main display's profile, read at launch through `osascript`/AppKit `NSScreen.colorSpace.ICCProfileData`, no FFI; elsewhere sRGB), a built-in RGB profile or an `.icc` path. The profile is not re-read when the window moves to another display.
- **HDR/EDR output** (an `rgba16float` surface with an extended-range colorspace) is a later-phase feature. The interfaces already carry `f32` pixels.
### 7.4 Oracle