mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-17 04:39:54 +02:00
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:
parent
5bd3dac660
commit
d5eb44e9fd
|
|
@ -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()
|
||||
|
|
|
|||
98
apps/photocraft/src/monitor_profile.rs
Normal file
98
apps/photocraft/src/monitor_profile.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(¶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]);
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
76
crates/engine/examples/bench_display.rs
Normal file
76
crates/engine/examples/bench_display.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
236
crates/engine/src/display_color.rs
Normal file
236
crates/engine/src/display_color.rs
Normal 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;
|
||||
251
crates/engine/src/display_color_tests.rs
Normal file
251
crates/engine/src/display_color_tests.rs
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
"#;
|
||||
|
||||
|
|
|
|||
80
crates/ui-egui/tests/color_managed_canvas.rs
Normal file
80
crates/ui-egui/tests/color_managed_canvas.rs
Normal 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:?}");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user