diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index b08ae150b..4796740ea 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -113,6 +113,7 @@ impl WireJob { depth: lightcraft_engine::pipeline::OutputDepth::U8, proof: None, window: self.window.map(|[x, y, w, h]| lightcraft_engine::pipeline::PixelWindow { x, y, w, h }), + display: None, } } diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index 52a323289..f321bdb9b 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -293,6 +293,7 @@ impl Workers { rendered: Err(msg), loaded: None, quick: None, + display: None, }; g.done.push((b.slot, r, 0.0)); } @@ -379,6 +380,7 @@ impl Workers { rendered, loaded: None, quick: None, + display: None, }, ms, )); @@ -431,6 +433,7 @@ impl RenderOffload for Workers { rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, + display: None, }; g.done.push((slot, r, 0.0)); return None; diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 832b158b3..348e85495 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -133,6 +133,23 @@ fn gpu_crash_notice(what: &str) -> String { ) } +/// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's +/// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. +fn display_profile_dir() -> Option { + let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).filter(|h| !h.is_empty()).map(std::path::PathBuf::from); + let user = |rel: &str| home.as_ref().map(|h| h.join(rel)); + let candidates: Vec> = if cfg!(target_os = "macos") { + vec![user("Library/ColorSync/Profiles"), Some("/Library/ColorSync/Profiles".into())] + } else if cfg!(windows) { + let root = std::env::var_os("SystemRoot").map(std::path::PathBuf::from).unwrap_or_else(|| "C:\\Windows".into()); + vec![Some(root.join("System32").join("spool").join("drivers").join("color"))] + } else { + let data = std::env::var_os("XDG_DATA_HOME").filter(|d| !d.is_empty()).map(std::path::PathBuf::from).or_else(|| user(".local/share")); + vec![data.map(|d| d.join("icc")), user(".color/icc"), Some("/usr/share/color/icc".into())] + }; + candidates.into_iter().flatten().find(|d| d.is_dir()) +} + fn config_dir() -> Option { lightcraft_engine::config::config_dir() } @@ -464,6 +481,15 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .map(|p| vec![p.to_string_lossy().to_string()]) .unwrap_or_default() })), + pick_display_profile: Some(Box::new(|| { + let mut d = rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("ICC Profiles"), &["icc", "icm"]); + if let Some(dir) = display_profile_dir() { + d = d.set_directory(dir); + } + d.pick_file().map(|p| vec![p.to_string_lossy().to_string()]).unwrap_or_default() + })), save_preset_file: Some(Box::new(|name: &str| { rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Export Presets")) diff --git a/crates/codecs/src/display.rs b/crates/codecs/src/display.rs new file mode 100644 index 000000000..e26e6ab6e --- /dev/null +++ b/crates/codecs/src/display.rs @@ -0,0 +1,442 @@ +//! Monitor (display) ICC profiles: showing photos correctly on wide-gamut and calibrated displays. +//! +//! The loupe renders straight into the display's own primaries: the develop pipeline targets the +//! display's linear RGB ([`DisplayProfile::to_rec2020`]) and encodes it with the sRGB curve, as it +//! does for every 8-bit output. That "proxy" encoding then goes to the display's real device +//! values with [`DisplayProfile::correct`]: its tone curves for a matrix/TRC profile (a per-channel +//! table), or its LUTs for a LUT-based profile (a CMS transform). Images made for sRGB (thumbnails, +//! cached previews, embedded camera JPEGs) go through [`DisplayProfile::from_srgb`] instead. +//! +//! All conversions are relative colorimetric: the display's white is the image's white. + +use std::sync::Arc; + +use crate::icc; +use crate::space::Trc; +use lightcraft_color::{D50, D65, Mat3, REC2020, bradford}; +use lightcraft_raster::Rgba8; +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, Transform8BitExecutor, TransformOptions, Xyzd}; + +/// Largest profile file accepted (monitor profiles are a few KB; LUT profiles up to a few MB). +pub const MAX_PROFILE_BYTES: usize = 32 << 20; + +/// How a display profile describes the display. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DisplayKind { + /// Primaries + tone curves (exact). + MatrixTrc, + /// LUT-based (A2B/B2A tables): applied through the CMS; the primaries the pipeline targets are + /// the profile's matrix tags when it has them, else measured through its tables. + Lut, +} + +impl DisplayKind { + pub fn label(self) -> &'static str { + match self { + DisplayKind::MatrixTrc => "matrix", + DisplayKind::Lut => "lut", + } + } +} + +/// The proxy → device step of a display. +enum Correction { + /// The proxy encoding is the display's (sRGB-curve matrix profile): nothing to do. + None, + /// Per-channel tables (matrix/TRC profile: same primaries, other curves). + Curves(Box<[[u8; 256]; 3]>), + /// A full CMS transform (LUT-based profile). + Cms(Arc), +} + +/// A parsed display profile with its transforms (built once, applied to every preview). +pub struct DisplayProfile { + /// The profile's own description (or "Display profile"). + pub description: String, + /// Identifies the profile (hash of its bytes): part of render keys. + pub id: u64, + pub kind: DisplayKind, + /// Linear display RGB → linear Rec.2020 (D65; display white → (1, 1, 1)). + pub to_rec2020: Mat3, + correction: Correction, + from_srgb: Arc, + to_srgb: Arc, +} + +impl std::fmt::Debug for DisplayProfile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DisplayProfile") + .field("description", &self.description) + .field("id", &self.id) + .field("kind", &self.kind) + .field("to_rec2020", &self.to_rec2020) + .finish_non_exhaustive() + } +} + +fn opts() -> TransformOptions { + TransformOptions { rendering_intent: RenderingIntent::RelativeColorimetric, ..TransformOptions::default() } +} + +fn xyzd(v: [f64; 3]) -> Xyzd { + Xyzd { x: v[0], y: v[1], z: v[2] } +} + +/// Linear Rec.2020 → XYZ relative to D50 (the ICC PCS), Bradford-adapted. +fn rec2020_to_xyz_d50() -> Mat3 { + bradford(D65, D50).mul(&REC2020.to_xyz()) +} + +/// Scale `m`'s columns so that (1, 1, 1) maps to (1, 1, 1): the display's white is the image's. +fn white_balanced(m: Mat3) -> Option { + let s = m.inverse()?.apply([1.0; 3]); + s.iter().all(|v| v.is_finite() && *v > 0.0).then(|| m.mul(&Mat3::diag(s[0], s[1], s[2]))) +} + +fn hash(bytes: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + h.finish() | 1 +} + +/// Runs a moxcms call that may panic on a hostile profile. +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +impl DisplayProfile { + /// Parse a display profile (an RGB ICC profile, matrix/TRC or LUT-based) and build its + /// transforms. Errors say what is wrong with the file; nothing here panics. + pub fn from_icc(bytes: &[u8]) -> Result { + if bytes.len() > MAX_PROFILE_BYTES { + return Err(format!("the profile is too large ({} MB)", bytes.len() >> 20)); + } + let p = guarded(|| ColorProfile::new_from_slice(bytes).ok()).ok_or("not a valid ICC profile")?; + if p.color_space != DataColorSpace::Rgb { + return Err("not an RGB profile (a display profile describes an RGB device)".into()); + } + if !matches!(p.pcs, DataColorSpace::Xyz | DataColorSpace::Lab) { + return Err("unsupported profile connection space".into()); + } + let info = icc::parse(bytes).ok_or("not a valid ICC profile")?; + let description = info + .description + .as_deref() + .map(|d| d.trim_start_matches('\u{feff}').trim()) + .filter(|d| !d.is_empty()) + .unwrap_or("Display profile") + .to_string(); + let has_lut = p.lut_a_to_b_colorimetric.is_some() + || p.lut_a_to_b_perceptual.is_some() + || p.lut_b_to_a_colorimetric.is_some() + || p.lut_b_to_a_perceptual.is_some(); + let srgb = ColorProfile::new_srgb(); + + // the primaries the pipeline renders into + let (kind, to_xyz_d50, curves) = match &info.kind { + icc::IccKind::MatrixTrc { to_xyz_d50, trc } if !has_lut => (DisplayKind::MatrixTrc, *to_xyz_d50, Some(trc.clone())), + icc::IccKind::MatrixTrc { to_xyz_d50, .. } => (DisplayKind::Lut, *to_xyz_d50, None), + _ => (DisplayKind::Lut, measured_to_xyz_d50(&p)?, None), + }; + let to_rec2020 = + rec2020_to_xyz_d50().inverse().map(|m| m.mul(&to_xyz_d50)).and_then(white_balanced).ok_or("the profile's primaries are degenerate")?; + if to_rec2020.0.iter().flatten().any(|v| !v.is_finite()) { + return Err("the profile's primaries are degenerate".into()); + } + + // the proxy: the display's primaries (as the pipeline targets them), sRGB curve + let proxy_xyz = rec2020_to_xyz_d50().mul(&to_rec2020).0; + let mut proxy = ColorProfile::new_srgb(); + proxy.cicp = None; + proxy.red_colorant = xyzd([proxy_xyz[0][0], proxy_xyz[1][0], proxy_xyz[2][0]]); + proxy.green_colorant = xyzd([proxy_xyz[0][1], proxy_xyz[1][1], proxy_xyz[2][1]]); + proxy.blue_colorant = xyzd([proxy_xyz[0][2], proxy_xyz[1][2], proxy_xyz[2][2]]); + + let transform = |src: &ColorProfile, dst: &ColorProfile| guarded(|| src.create_transform_8bit(Layout::Rgba, dst, Layout::Rgba, opts()).ok()); + let correction = match curves { + Some(trc) if trc.iter().all(|t| t.approx_eq(&Trc::Srgb, 2e-4)) => Correction::None, + Some(trc) => Correction::Curves(Box::new(curve_tables(&trc))), + None => Correction::Cms(transform(&proxy, &p).ok_or("the profile's tables can't be applied")?), + }; + let from_srgb = transform(&srgb, &p).ok_or("can't convert sRGB to this profile")?; + let to_srgb = transform(&proxy, &srgb).ok_or("can't convert this profile to sRGB")?; + Ok(DisplayProfile { description, id: hash(bytes), kind, to_rec2020, correction, from_srgb, to_srgb }) + } + + /// Does [`correct`](Self::correct) change anything (the display's curves aren't sRGB's)? + pub fn needs_correction(&self) -> bool { + !matches!(self.correction, Correction::None) + } + + /// A render in the display's primaries with the sRGB curve (the pipeline's display target) → + /// the display's device values, in place. + pub fn correct(&self, img: &mut Rgba8) -> Result<(), String> { + match &self.correction { + Correction::None => Ok(()), + Correction::Curves(t) => { + let map = |p: &mut [u8; 4]| *p = [t[0][p[0] as usize], t[1][p[1] as usize], t[2][p[2] as usize], p[3]]; + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + img.data.par_chunks_mut(1 << 14).for_each(|c| c.iter_mut().for_each(map)); + } + #[cfg(not(feature = "parallel"))] + img.data.iter_mut().for_each(map); + Ok(()) + } + Correction::Cms(x) => apply(x, img), + } + } + + /// An sRGB image → the display's device values, in place. + pub fn from_srgb(&self, img: &mut Rgba8) -> Result<(), String> { + apply(&self.from_srgb, img) + } + + /// A render in the display's primaries with the sRGB curve → sRGB (colours outside sRGB are + /// clipped): for histograms and cached previews, which stay sRGB. + pub fn to_srgb(&self, img: &Rgba8) -> Result { + let mut out = img.clone(); + apply(&self.to_srgb, &mut out)?; + Ok(out) + } +} + +/// Device RGB → PCS (XYZ D50) of the pure primaries, measured through the profile's tables +/// (LUT-only profiles have no colorant tags). +fn measured_to_xyz_d50(p: &ColorProfile) -> Result { + let lin = icc::linear_rec2020_profile(); + let x = guarded(|| p.create_transform_f32(Layout::Rgb, &lin, Layout::Rgb, opts()).ok()).ok_or("the profile's tables can't be applied")?; + let src = [1.0f32, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]; + let mut dst = [0f32; 9]; + guarded(|| x.transform(&src, &mut dst).ok()).ok_or("the profile's tables can't be applied")?; + let c = |i: usize| [dst[i] as f64, dst[i + 3] as f64, dst[i + 6] as f64]; + // columns: each primary in linear Rec.2020 + let m = Mat3([c(0), c(1), c(2)]); + if m.0.iter().flatten().any(|v| !v.is_finite()) || m.determinant().abs() < 1e-6 { + return Err("the profile's primaries are degenerate".into()); + } + Ok(rec2020_to_xyz_d50().mul(&m)) +} + +/// sRGB-encoded 8-bit value → the display curve's code value, per channel. +fn curve_tables(trc: &[Trc; 3]) -> [[u8; 256]; 3] { + let mut t = [[0u8; 256]; 3]; + for (k, tab) in t.iter_mut().enumerate() { + for (i, v) in tab.iter_mut().enumerate() { + let lin = lightcraft_color::transfer::srgb_to_linear(i as f32 / 255.0); + *v = (from_linear(&trc[k], lin) * 255.0 + 0.5).clamp(0.0, 255.0) as u8; + } + } + t +} + +/// Inverse of [`Trc::to_linear`] on 0..1 (bisection: works for tables and every parametric form). +fn from_linear(t: &Trc, y: f32) -> f32 { + let y = y.clamp(0.0, 1.0); + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..24 { + let mid = 0.5 * (lo + hi); + if t.to_linear(mid) < y { + lo = mid; + } else { + hi = mid; + } + } + 0.5 * (lo + hi) +} + +fn apply(x: &Arc, img: &mut Rgba8) -> Result<(), String> { + const CHUNK: usize = 1 << 14; // pixels + let src = img.data.clone(); + let run = |s: &[[u8; 4]], d: &mut [[u8; 4]]| guarded(|| x.transform(s.as_flattened(), d.as_flattened_mut()).ok()).is_some(); + #[cfg(feature = "parallel")] + let ok = { + use rayon::prelude::*; + src.par_chunks(CHUNK).zip(img.data.par_chunks_mut(CHUNK)).all(|(s, d)| run(s, d)) + }; + #[cfg(not(feature = "parallel"))] + let ok = src.chunks(CHUNK).zip(img.data.chunks_mut(CHUNK)).all(|(s, d)| run(s, d)); + if ok { Ok(()) } else { Err("display profile transform failed".into()) } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::space::NamedSpace; + use lightcraft_color::{DISPLAY_P3, SRGB}; + + fn img(px: &[[u8; 4]]) -> Rgba8 { + Rgba8 { width: px.len(), height: 1, data: px.to_vec() } + } + + fn close(a: [u8; 4], b: [u8; 4], tol: i32) -> bool { + (0..4).all(|i| (a[i] as i32 - b[i] as i32).abs() <= tol) + } + + #[test] + fn srgb_display_changes_nothing() { + let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::Srgb)).unwrap(); + assert_eq!(d.kind, DisplayKind::MatrixTrc); + assert!(!d.needs_correction()); + for (i, row) in d.to_rec2020.0.iter().enumerate() { + let want = SRGB.to_space(&REC2020).0[i]; + for k in 0..3 { + assert!((row[k] - want[k]).abs() < 2e-3, "{:?}", d.to_rec2020); + } + } + let px = [[0, 0, 0, 255], [255, 255, 255, 255], [200, 30, 90, 128], [17, 140, 251, 7]]; + let mut a = img(&px); + d.correct(&mut a).unwrap(); + assert_eq!(a.data, px); + let mut b = img(&px); + d.from_srgb(&mut b).unwrap(); + for (x, y) in b.data.iter().zip(&px) { + assert!(close(*x, *y, 1), "{x:?} {y:?}"); + } + } + + /// On a Display P3 monitor, sRGB red is shown with less red drive than full; P3 green (out of + /// sRGB) is reachable in the display's own primaries. + #[test] + fn p3_display() { + let d = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap(); + assert!(!d.needs_correction(), "P3 uses the sRGB curve"); + let want = DISPLAY_P3.to_space(&REC2020).0; + for i in 0..3 { + for k in 0..3 { + assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 2e-3, "{:?}", d.to_rec2020); + } + } + let mut red = img(&[[255, 0, 0, 255], [128, 128, 128, 255]]); + d.from_srgb(&mut red).unwrap(); + assert!(red.data[0][0] < 245 && red.data[0][1] > 40, "{:?}", red.data[0]); + assert!(close(red.data[1], [128, 128, 128, 255], 1), "grey stays grey: {:?}", red.data[1]); + // the display's own pure green is outside sRGB: it clips there + let srgb = d.to_srgb(&img(&[[0, 255, 0, 255]])).unwrap(); + assert!(srgb.data[0][0] < 10 && srgb.data[0][1] == 255, "{:?}", srgb.data[0]); + } + + /// A gamma 2.2 display (Adobe RGB-like): the proxy's sRGB curve goes to the display's curve. + #[test] + fn gamma_display_uses_its_curve() { + let bytes = icc::write_matrix_trc(&lightcraft_color::ADOBE_RGB, &Trc::Gamma(2.2)); + let d = DisplayProfile::from_icc(&bytes).unwrap(); + assert!(d.needs_correction()); + let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [10, 128, 240, 9]]); + d.correct(&mut a).unwrap(); + assert_eq!(a.data[0], [0, 0, 0, 255]); + assert_eq!(a.data[1], [255, 255, 255, 255]); + for (k, v) in [10u8, 128, 240].into_iter().enumerate() { + let lin = lightcraft_color::transfer::srgb_to_linear(v as f32 / 255.0); + let want = (lin.powf(1.0 / 2.2) * 255.0).round() as i32; + assert!((a.data[2][k] as i32 - want).abs() <= 1, "{k}: {} vs {want}", a.data[2][k]); + } + assert_eq!(a.data[2][3], 9, "alpha kept"); + } + + #[test] + fn rejects_bad_profiles() { + assert!(DisplayProfile::from_icc(&[]).is_err()); + assert!(DisplayProfile::from_icc(&[0u8; 300]).is_err()); + let mut gray = ColorProfile::new_gray_with_gamma(2.2); + gray.cicp = None; + assert!(DisplayProfile::from_icc(&gray.encode().unwrap()).is_err()); + // truncated / corrupted real profiles never panic + let good = icc::write_named(NamedSpace::DisplayP3); + for n in [10, 128, 140, 200, good.len() - 1] { + let _ = DisplayProfile::from_icc(&good[..n]); + } + for i in (0..good.len()).step_by(7) { + let mut b = good.clone(); + b[i] ^= 0xa5; + let _ = DisplayProfile::from_icc(&b); + } + } + + /// A LUT-based display profile with no colorant or TRC tags (its A2B/B2A tables describe a + /// Display P3 panel with a gamma 2.2 response): the primaries are measured through the tables + /// and every preview goes through the CMS. + fn lut_only_p3_gamma22() -> Vec { + use moxcms::{LutMultidimensionalType, LutWarehouse, Matrix3d, ProfileClass, ToneReprCurve, Vector3d}; + let m = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).0; + let inv = crate::space::rgb_to_xyz_d50(&DISPLAY_P3).inverse().unwrap().0; + let curves = |v: Vec| vec![ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v.clone()), ToneReprCurve::Parametric(v)]; + let ident = || curves(vec![1.0]); + // mAB: A → CLUT → M → matrix → B (no CLUT: M curves linearize, the matrix goes to XYZ) + let a2b = LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: curves(vec![2.2]), + matrix: Matrix3d { v: m.map(|r| r.map(|v| v * 32768.0 / 65535.0)) }, + b_curves: ident(), + bias: Vector3d::default(), + }); + let b2a = LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: curves(vec![1.0 / 2.2]), + matrix: Matrix3d { v: inv.map(|r| r.map(|v| v * 65535.0 / 32768.0)) }, + b_curves: ident(), + bias: Vector3d::default(), + }); + let mut p = ColorProfile::new_srgb(); + p.cicp = None; + p.profile_class = ProfileClass::DisplayDevice; + p.red_colorant = Xyzd::default(); + p.green_colorant = Xyzd::default(); + p.blue_colorant = Xyzd::default(); + p.red_trc = None; + p.green_trc = None; + p.blue_trc = None; + p.lut_a_to_b_colorimetric = Some(a2b.clone()); + p.lut_a_to_b_perceptual = Some(a2b); + p.lut_b_to_a_colorimetric = Some(b2a.clone()); + p.lut_b_to_a_perceptual = Some(b2a); + p.encode().unwrap() + } + + #[test] + fn lut_profile_goes_through_the_cms() { + let bytes = lut_only_p3_gamma22(); + let parsed = ColorProfile::new_from_slice(&bytes).unwrap(); + assert!(!parsed.is_matrix_shaper() && parsed.lut_b_to_a_colorimetric.is_some(), "a LUT-only profile"); + let d = DisplayProfile::from_icc(&bytes).unwrap(); + assert_eq!(d.kind, DisplayKind::Lut); + assert!(d.needs_correction()); + // the measured primaries are P3's + let want = DISPLAY_P3.to_space(&REC2020).0; + for i in 0..3 { + for k in 0..3 { + assert!((d.to_rec2020.0[i][k] - want[i][k]).abs() < 0.02, "{:?} vs {want:?}", d.to_rec2020); + } + } + // the correction is the gamma 2.2 curve; grey stays grey + let mut a = img(&[[0, 0, 0, 255], [255, 255, 255, 255], [128, 128, 128, 200]]); + d.correct(&mut a).unwrap(); + assert!(close(a.data[0], [0, 0, 0, 255], 1) && close(a.data[1], [255, 255, 255, 255], 1), "{:?}", a.data); + let lin = lightcraft_color::transfer::srgb_to_linear(128.0 / 255.0); + let g = (lin.powf(1.0 / 2.2) * 255.0).round() as u8; + assert!(close(a.data[2], [g, g, g, 200], 2), "{:?} vs {g}", a.data[2]); + // sRGB red → less than full red drive on a P3 panel + let mut red = img(&[[255, 0, 0, 255]]); + d.from_srgb(&mut red).unwrap(); + assert!(red.data[0][0] < 245 && red.data[0][1] > 30, "{:?}", red.data[0]); + } + + #[test] + fn ids_identify_profiles() { + let a = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap(); + let b = DisplayProfile::from_icc(&icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + let a2 = DisplayProfile::from_icc(&icc::write_named(NamedSpace::DisplayP3)).unwrap(); + assert_ne!(a.id, b.id); + assert_eq!(a.id, a2.id); + } +} diff --git a/crates/codecs/src/icc.rs b/crates/codecs/src/icc.rs index ec9e8efe3..c3de9edde 100644 --- a/crates/codecs/src/icc.rs +++ b/crates/codecs/src/icc.rs @@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec { write_matrix_trc(&n.rgb_space(), &n.trc()) } -fn linear_rec2020_profile() -> ColorProfile { +pub(crate) fn linear_rec2020_profile() -> ColorProfile { let mut p = ColorProfile::new_bt2020(); p.cicp = None; let lin = ToneReprCurve::Parametric(vec![1.0]); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index 169f40c11..619c927ce 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -17,6 +17,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; +pub mod display; pub mod encode; pub mod exif; mod heif; diff --git a/crates/engine/src/display.rs b/crates/engine/src/display.rs new file mode 100644 index 000000000..f47e099b6 --- /dev/null +++ b/crates/engine/src/display.rs @@ -0,0 +1,176 @@ +//! The monitor profile (`app.displayProfile`): previews shown on a wide-gamut or calibrated +//! display through its ICC profile. +//! +//! The frontend holds the profile in use (Settings ▸ Display, the UI command `app.displayProfile`). +//! With a profile, the loupe renders into the display's own primaries +//! ([`RenderJob::with_display`](crate::media::RenderJob::with_display)) and everything else made +//! for sRGB (thumbnails, cached previews, embedded camera JPEGs) is converted to it +//! ([`present`]). Histograms and the preview caches stay sRGB, and exports never use it. + +pub use lightcraft_codecs::display::DisplayKind; +use lightcraft_codecs::display::{DisplayProfile, MAX_PROFILE_BYTES}; +use lightcraft_pipeline::DisplaySpace; +use lightcraft_raster::{Histogram, Rgba8}; +use serde_json::{Value, json}; +use std::sync::Arc; + +use crate::media::RenderResult; + +/// A loaded display profile: its transforms and the pipeline target. +#[derive(Debug)] +pub struct Display { + /// The file it came from. + pub path: String, + pub profile: DisplayProfile, + pub space: DisplaySpace, +} + +impl Display { + /// Load the ICC profile at `path`. + pub fn load(path: &str) -> Result { + let path = path.trim(); + let meta = std::fs::metadata(path).map_err(|e| format!("{path}: {e}"))?; + if meta.len() > MAX_PROFILE_BYTES as u64 { + return Err(format!("{path}: too large for an ICC profile")); + } + let bytes = std::fs::read(path).map_err(|e| format!("{path}: {e}"))?; + Display::from_bytes(path, &bytes) + } + + pub fn from_bytes(path: &str, bytes: &[u8]) -> Result { + let profile = DisplayProfile::from_icc(bytes).map_err(|e| format!("{path}: {e}"))?; + let space = DisplaySpace::new(profile.to_rec2020, profile.id).ok_or_else(|| format!("{path}: the profile's primaries are degenerate"))?; + Ok(Display { path: path.to_string(), profile, space }) + } + + pub fn id(&self) -> u64 { + self.profile.id + } + + /// Load the profile at `path` (`""`: none, the display is treated as sRGB). + pub fn load_opt(path: &str) -> Result>, String> { + if path.trim().is_empty() { Ok(None) } else { Display::load(path).map(|d| Some(Arc::new(d))) } + } + + /// For `app.displayProfile` and Settings: path, description, kind, primaries (xy). + pub fn describe(&self) -> Value { + let to_xyz = lightcraft_color::REC2020.to_xyz().mul(&self.profile.to_rec2020).0; + let xy = |c: usize| { + let (x, y, z) = (to_xyz[0][c], to_xyz[1][c], to_xyz[2][c]); + let s = x + y + z; + if s.abs() < 1e-12 { json!([0.0, 0.0]) } else { json!([round4(x / s), round4(y / s)]) } + }; + json!({ + "path": self.path, + "description": self.profile.description, + "kind": self.profile.kind.label(), + "primaries": {"red": xy(0), "green": xy(1), "blue": xy(2)}, + }) + } +} + +fn round4(v: f64) -> f64 { + (v * 1e4).round() / 1e4 +} + +/// Make `r` ready for `display`: an sRGB image (thumbnail, cached preview, camera JPEG) is +/// converted to the display's values. A render made for the display (see +/// [`RenderJob::with_display`](crate::media::RenderJob::with_display)) is already. +pub fn present(r: &mut RenderResult, display: &Display) { + if r.display.is_some() { + return; + } + if let Ok(rendered) = &mut r.rendered { + match display.profile.from_srgb(&mut rendered.image) { + Ok(()) => r.display = Some(display.id()), + Err(e) => log::warn!("display profile: {e}"), + } + } +} + +/// A render into `display`'s primaries (sRGB curve) → in place, the display's device values; and +/// its sRGB histogram, plus (`full_srgb`: for the cached view preview) its whole sRGB version. +/// The histogram samples the same pixels [`Histogram::of_srgb8`] would, so only those are +/// converted when the sRGB image itself isn't needed (slider drags). +pub(crate) fn finish_render(image: &mut Rgba8, display: &Display, full_srgb: bool) -> (Option, Option) { + let warn = |e: String| log::warn!("display profile: {e}"); + let (srgb, histogram) = if full_srgb { + let srgb = display.profile.to_srgb(image).map_err(warn).ok(); + let h = srgb.as_ref().map(Histogram::of_srgb8); + (srgb, h) + } else { + let sample = histogram_sample(image); + (None, display.profile.to_srgb(&sample).map_err(warn).ok().map(|s| Histogram::of_srgb8(&s))) + }; + if let Err(e) = display.profile.correct(image) { + warn(e); + } + (srgb, histogram) +} + +/// The pixels [`Histogram::of_srgb8`] reads (at most ~1 MP, a regular grid), as an image of +/// their own (which it then reads whole). +fn histogram_sample(img: &Rgba8) -> Rgba8 { + let step = ((img.len() as f64 / 1_000_000.0).sqrt().ceil() as usize).max(1); + if step == 1 { + return img.clone(); + } + let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); + let mut data = Vec::with_capacity(w * h); + for y in (0..img.height).step_by(step) { + for x in (0..img.width).step_by(step) { + data.push(img.get(x, y)); + } + } + Rgba8 { width: w, height: h, data } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn p3() -> Display { + Display::from_bytes("p3.icc", &lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap() + } + + fn picture(w: usize, h: usize) -> Rgba8 { + Rgba8 { width: w, height: h, data: (0..w * h).map(|i| [(i * 7 % 256) as u8, (i * 13 % 256) as u8, (i * 29 % 256) as u8, 255]).collect() } + } + + /// The sampled histogram of a drag render is exactly the histogram of the whole sRGB image. + #[test] + fn sampled_histogram_matches_the_full_one() { + let d = p3(); + for (w, h) in [(40, 30), (1500, 1100), (2561, 1709)] { + let img = picture(w, h); + let (mut a, mut b) = (img.clone(), img.clone()); + let (srgb, full) = finish_render(&mut a, &d, true); + let (none, sampled) = finish_render(&mut b, &d, false); + assert!(srgb.is_some() && none.is_none()); + assert_eq!(full.unwrap(), sampled.unwrap(), "{w}x{h}"); + assert_eq!(a, b); + } + } + + #[test] + fn load_reports_and_rejects() { + let dir = std::env::temp_dir().join(format!("lightcraft-display-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let good = dir.join("p3.icc"); + std::fs::write(&good, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); + let bad = dir.join("bad.icc"); + std::fs::write(&bad, b"not a profile").unwrap(); + + let d = Display::load_opt(&good.to_string_lossy()).unwrap().unwrap(); + let r = d.describe(); + assert_eq!(r["kind"], "matrix"); + assert_eq!(r["description"], "Display P3"); + let red = r["primaries"]["red"].as_array().unwrap(); + assert!((red[0].as_f64().unwrap() - 0.68).abs() < 2e-3 && (red[1].as_f64().unwrap() - 0.32).abs() < 2e-3, "{r}"); + assert!(Display::load_opt(" ").unwrap().is_none()); + let e = Display::load(&bad.to_string_lossy()).unwrap_err(); + assert!(e.contains("not a valid ICC profile"), "{e}"); + assert!(Display::load(&dir.join("missing.icc").to_string_lossy()).is_err()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index 833d92e8e..aa66d374f 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -1371,6 +1371,7 @@ impl Session { cache: None, stages: None, view_cache: None, + display: None, }; let embedded = match (&self.media.preview_loader, c.kind) { (Some(l), MediaKind::Raw) => Some((c.path.clone(), l.clone(), edge)), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a6c5f6420..b255850bf 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -24,6 +24,7 @@ pub mod crs_masks; pub mod demo; pub mod denoise; pub mod devices; +pub mod display; pub mod export; pub mod face_download; #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 1231221b9..f96ef026b 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -674,6 +674,8 @@ pub struct RenderJob { pub stages: Option>, /// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]). pub view_cache: Option<(Arc, Hash128)>, + /// Render for this monitor profile ([`Self::with_display`]). + pub display: Option>, } pub struct RenderResult { @@ -687,6 +689,9 @@ pub struct RenderResult { pub loaded: Option, /// Set for a [`QuickJob`]'s stand-in: where the image came from. pub quick: Option, + /// The display profile (id) the image is encoded for; `None`: sRGB (see + /// [`crate::display::present`]). + pub display: Option, } impl RenderJob { @@ -734,6 +739,21 @@ impl RenderJob { self } + /// Render for a monitor profile: into the display's primaries (its gamut, not sRGB's), ready + /// to show ([`RenderResult::display`]). The histogram and the cached view preview stay sRGB. + /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: + /// [`crate::display::present`] converts their sRGB results. + pub fn with_display(mut self, display: Option>) -> Self { + if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + return self; + } + let k = |d: &Option>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); + self.key ^= k(&self.display) ^ k(&display); + self.request.display = display.as_ref().map(|d| d.space); + self.display = display; + self + } + pub fn run(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) @@ -749,6 +769,7 @@ impl RenderJob { rendered: Ok(Rendered { image, histogram, deep: None }), loaded: None, quick: None, + display: None, }; } let was_loaded = matches!(self.source, SourceRef::Loaded(_)); @@ -760,14 +781,30 @@ impl RenderJob { // Thumbnails (many small jobs side by side) stay on the CPU; views and exports use // the GPU when there is one. let gpu = self.cache.is_none(); - let rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + let mut rendered = develop(src, &info, &self.settings, &self.request, self.stages.as_deref(), gpu); + // for a display profile: the image goes to the display's values, the histogram + // and the cached view preview stay sRGB + let mut srgb = None; + let display = match (&self.display, self.request.display) { + (Some(d), Some(_)) => { + let keep = self.view_cache.is_some() && self.request.quality == Quality::Full; + let (s, h) = crate::display::finish_render(&mut rendered.image, d, keep); + if let Some(h) = h { + rendered.histogram = h; + } + srgb = s; + Some(d.id()) + } + _ => None, + }; if let Some((cache, key)) = &self.cache { cache.put_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); } if let Some((cache, key)) = &self.view_cache && self.request.quality == Quality::Full + && (display.is_none() || srgb.is_some()) { - cache.put_deferred_at(self.cache_generation, *key, Arc::new(rendered.image.clone())); + cache.put_deferred_at(self.cache_generation, *key, Arc::new(srgb.unwrap_or_else(|| rendered.image.clone()))); } RenderResult { request_id: self.request_id, @@ -778,6 +815,7 @@ impl RenderJob { rendered: Ok(rendered), loaded: (!was_loaded).then_some(source), quick: None, + display, } } Err(e) => RenderResult { @@ -789,6 +827,7 @@ impl RenderJob { rendered: Err(e), loaded: None, quick: None, + display: None, }, } } @@ -853,6 +892,7 @@ impl QuickJob { rendered, loaded: None, quick, + display: None, }; let rendered = |image: Rgba8| { let histogram = Histogram::of_srgb8(&image); @@ -972,6 +1012,7 @@ impl crate::Session { cache, stages: None, view_cache: None, + display: None, }) } @@ -1022,6 +1063,7 @@ impl crate::Session { cache: Some((self.media.rendered.clone(), ck)), stages: None, view_cache: None, + display: None, }) } @@ -1067,6 +1109,7 @@ impl crate::Session { cache: Some((self.media.rendered.clone(), ck)), stages: None, view_cache: None, + display: None, }) } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 4a7c27ce6..45a5a5f6d 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -639,7 +639,10 @@ pub fn render( lap("masks", &mut t, &mut cx); // 5. per-pixel stage - let fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + let mut fp = FinishParams::new(s, &plan.frame, info, w, h, plan.px_per_long, prep.air, req.space); + if let Some(d) = &req.display { + fp.for_display(d, None); + } let present = Present { clarity: prep.clarity.is_some(), texture: prep.texture.is_some(), diff --git a/crates/pipeline/src/finish.rs b/crates/pipeline/src/finish.rs index 6b422ee9a..04db51464 100644 --- a/crates/pipeline/src/finish.rs +++ b/crates/pipeline/src/finish.rs @@ -260,12 +260,32 @@ impl FinishParams { view: frame.view.map_or([0.0, 0.0, w as f32, h as f32], |v| [v.x as f32, v.y as f32, v.full_w as f32, v.full_h as f32]), } } + + /// Render into a display's primaries (sRGB curve) instead of the output space, soft proof + /// included (see [`crate::DisplaySpace`]). + pub fn for_display(&mut self, d: &crate::DisplaySpace, proof: Option) { + self.to_out = d.from_working; + self.out_luma = d.luma; + self.out_trc = OutputTrc::Srgb; + self.proof = proof.map(|pr| pr.params_display(d)); + } } -pub(crate) fn finish(p: &Prepared, s: &DevelopSettings, frame: &Frame, info: &SourceInfo, space: OutputSpace, proof: Option) -> Rgba8 { +pub(crate) fn finish( + p: &Prepared, + s: &DevelopSettings, + frame: &Frame, + info: &SourceInfo, + space: OutputSpace, + display: Option<&crate::DisplaySpace>, + proof: Option, +) -> Rgba8 { let (w, h) = (p.img.width, p.img.height); let mut fp = FinishParams::new(s, frame, info, w, h, p.px_per_long, p.air, space); fp.proof = proof.map(|pr| pr.params(space)); + if let Some(d) = display { + fp.for_display(d, proof); + } let trc = fp.out_trc; let data = finish_with(p, &fp, false, |e| match trc { OutputTrc::Srgb => [enc(e[0]), enc(e[1]), enc(e[2]), 255], diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index 274bcd61c..433162a01 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -43,7 +43,7 @@ pub mod transform; pub mod upright; pub mod visualize; -pub use output::{DeepImage, DeepSamples, OutputDepth, OutputSpace, OutputTrc, Proof}; +pub use output::{DeepImage, DeepSamples, DisplaySpace, OutputDepth, OutputSpace, OutputTrc, Proof}; pub use visualize::{MaskView, Overlay}; use lightcraft_develop::{DevelopSettings, Treatment}; @@ -132,6 +132,9 @@ pub struct RenderRequest { /// Render only this window of the output that `max_w × max_h` describes (a zoomed view): /// the result is that window's pixels, as in the whole render. See [`PixelWindow`]. pub window: Option, + /// Previews for a monitor with a display profile: render 8-bit into its primaries instead of + /// `space` (see [`DisplaySpace`]; ignored by deep renders). + pub display: Option, } /// A window of the (virtual) full output, in its pixels: what a zoomed view needs, rendered @@ -168,6 +171,7 @@ impl RenderRequest { depth: OutputDepth::U8, proof: None, window: None, + display: None, } } } @@ -525,7 +529,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende lap("finish (deep)", &mut t); return Rendered { image, histogram, deep: Some(deep) }; } - let image = finish::finish(&prep, s, frame, info, req.space, req.proof); + let image = finish::finish(&prep, s, frame, info, req.space, req.display.as_ref(), req.proof); lap("finish", &mut t); let cut = |i: &Rgba8| match plan.keep { Some(k) => i.crop(k.x, k.y, k.w, k.h), diff --git a/crates/pipeline/src/output.rs b/crates/pipeline/src/output.rs index 5272b54d1..9fc14b3e7 100644 --- a/crates/pipeline/src/output.rs +++ b/crates/pipeline/src/output.rs @@ -1,13 +1,14 @@ //! Output encoding: the colour space (primaries + transfer curve) and sample format a render is -//! delivered in. Previews are always 8-bit sRGB; exports may ask for a wide-gamut space and/or -//! 16-bit or linear float samples. +//! delivered in. Previews are 8-bit sRGB, or 8-bit in a monitor's own primaries when the frontend +//! has a display profile ([`DisplaySpace`]); exports may ask for a wide-gamut space and/or 16-bit or +//! linear float samples. //! //! The per-pixel stage works in scene-linear Rec.2020 and ends by converting to the target's //! primaries, gamut mapping into the *target* gamut, and encoding. Tone curves and grain operate on //! sRGB-curve-encoded values of the target primaries (identical to the sRGB path when the target is //! sRGB); afterwards the values are re-encoded with the target's own curve. -use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, PROPHOTO, REC2020, RgbSpace, SRGB}; +use lightcraft_color::{ADOBE_RGB, DISPLAY_P3, Mat3, PROPHOTO, REC2020, RgbSpace, SRGB}; use serde::{Deserialize, Serialize}; /// The RGB space an image is rendered into. @@ -138,6 +139,48 @@ impl OutputSpace { } } +/// A monitor's RGB as a preview target (from its ICC display profile): previews render into the +/// display's own primaries, gamut mapped into the display's gamut, encoded with the sRGB curve. +/// The frontend then takes that encoding to the display's real device values +/// (`lightcraft_codecs::display`). Exports never use it. +#[derive(Clone, Copy, Debug)] +pub struct DisplaySpace { + /// Linear Rec.2020 → linear display RGB, and back. + pub from_working: [[f32; 3]; 3], + pub to_working: [[f32; 3]; 3], + /// Luminance weights of the display's linear RGB (gamut mapping). + pub luma: [f32; 3], + /// Identifies the display profile (part of render keys). + pub id: u64, +} + +impl DisplaySpace { + /// From the display's linear RGB → linear Rec.2020 matrix (display white → (1, 1, 1)). + /// `None` for a singular or non-finite matrix. + pub fn new(to_working: Mat3, id: u64) -> Option { + let from = to_working.inverse()?; + let luma = REC2020.to_xyz().mul(&to_working).0[1]; + let all = to_working.0.iter().chain(from.0.iter()).flatten().chain(luma.iter()); + if all.clone().any(|v| !v.is_finite()) || (luma.iter().sum::() - 1.0).abs() > 1e-3 { + return None; + } + Some(DisplaySpace { from_working: from.to_f32(), to_working: to_working.to_f32(), luma: luma.map(|v| v as f32), id }) + } + + /// A display that is exactly `space`'s primaries (tests, and the sRGB identity). + pub fn of(space: OutputSpace, id: u64) -> Option { + DisplaySpace::new(space.rgb_space().to_space(&REC2020), id) + } +} + +impl PartialEq for DisplaySpace { + fn eq(&self, o: &Self) -> bool { + self.id == o.id + } +} + +impl Eq for DisplaySpace {} + /// Soft proofing: render as if the result were converted to `space` (colours outside its gamut /// are mapped into it, then shown in the render's own space), optionally painting what the /// destination can't hold (`dest_warning`, red) and what the display can't show @@ -179,6 +222,17 @@ impl Proof { display_warning: self.display_warning, } } + + /// [`Self::params`] for a render into a display's primaries: the display warning then shows + /// what *this* display can't show. + pub fn params_display(self, d: &DisplaySpace) -> ProofParams { + let proof_to_working = self.space.rgb_space().to_space(&REC2020).to_f32(); + ProofParams { proof_to_out: mul33(&d.from_working, &proof_to_working), ..self.params(OutputSpace::Srgb) } + } +} + +fn mul33(a: &[[f32; 3]; 3], b: &[[f32; 3]; 3]) -> [[f32; 3]; 3] { + std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum())) } /// Sample format of a render. diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index f572b21d2..fa3f164b7 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -360,6 +360,46 @@ fn soft_proof_maps_into_the_proof_gamut_and_flags_what_does_not_fit() { assert!(red(&pro) < n, "ProPhoto holds more than sRGB"); } +/// A preview for a monitor with a display profile renders into the display's primaries: for a +/// display that *is* a standard space, exactly what an 8-bit render into that space gives. +#[test] +fn display_space_renders_like_the_matching_output_space() { + use crate::{DisplaySpace, OutputSpace, Proof}; + let src = scene(); + let mut s = DevelopSettings::default(); + s.color.saturation = 100.0; + s.color.vibrance = 100.0; + let info = SourceInfo { raw: true, ..Default::default() }; + let req = |space, display, proof| RenderRequest { space, display, proof, ..RenderRequest::fit(120, 120) }; + for space in [OutputSpace::Srgb, OutputSpace::DisplayP3] { + let d = DisplaySpace::of(space, 7).unwrap(); + let want = render(&src, &info, &s, &req(space, None, None)).image; + let got = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(d), None)).image; + assert!(max_diff(&got, &want) <= 1, "{space:?}: {}", max_diff(&got, &want)); + } + // a wide display shows the saturated colours sRGB clips + let srgb = render(&src, &info, &s, &req(OutputSpace::Srgb, None, None)).image; + let p3 = render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::DisplayP3, 1).unwrap()), None)).image; + assert!(max_diff(&srgb, &p3) > 4); + + // soft proofing on a display: the display gamut warning is about *this* display + let blue = |img: &lightcraft_raster::Rgba8| img.data.iter().filter(|p| p[2] == 255 && p[0] == 0 && p[1] < 80).count(); + let pro = Some(Proof { space: OutputSpace::ProPhoto, dest_warning: false, display_warning: true }); + let on_srgb = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Srgb, 2).unwrap()), pro)).image); + let on_wide = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, Some(DisplaySpace::of(OutputSpace::Rec2020, 3).unwrap()), pro)).image); + assert!(on_srgb > 0 && on_wide < on_srgb, "{on_srgb} {on_wide}"); + let plain = blue(&render(&src, &info, &s, &req(OutputSpace::Srgb, None, pro)).image); + assert_eq!(plain, on_srgb, "an sRGB display warns like no display profile"); +} + +#[test] +fn display_space_rejects_degenerate_matrices() { + use crate::DisplaySpace; + assert!(DisplaySpace::new(lightcraft_color::Mat3([[1.0, 1.0, 0.0], [1.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), 1).is_none()); + assert!(DisplaySpace::new(lightcraft_color::Mat3([[f64::NAN, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]), 1).is_none()); + assert!(DisplaySpace::new(lightcraft_color::Mat3::IDENTITY, 1).is_some()); +} + #[test] fn tint_negative_is_green_and_positive_is_magenta() { use lightcraft_develop::WbMode; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e6929269f..41d8ff171 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2641,6 +2641,97 @@ mod tests { assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open"); } + /// Settings ▸ Display / `app.displayProfile`: previews are shown through the monitor profile + /// (the loupe rendered for the display, thumbnails converted from sRGB, the histogram still + /// sRGB); a file that can't be used is an error, or, as the setting, reported with previews + /// left sRGB. + #[test] + fn display_profile_converts_previews() { + use crate::render::Slot; + let dir = std::env::temp_dir().join(format!("lightcraft-display-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p3 = dir.join("p3.icc"); + std::fs::write(&p3, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::DisplayP3)).unwrap(); + let bad = dir.join("bad.icc"); + std::fs::write(&bad, b"not a profile").unwrap(); + let (p3, bad) = (p3.to_string_lossy().to_string(), bad.to_string_lossy().to_string()); + + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + let thumb_slot = + |h: &Headless| h.app.renderer.textures.keys().copied().filter(|s| matches!(s, Slot::Thumb(_))).min_by_key(|s| format!("{s:?}")); + let ready = |h: &Headless| { + h.app.renderer.textures.get(&Slot::Main).is_some_and(|t| t.pixels.is_some()) + && thumb_slot(h).is_some() + && !h.app.renderer.is_pending(Slot::Main) + }; + assert!(h.step_until(SETTLE, ready)); + h.settle(SETTLE); + let rgba = |c: &egui::ColorImage| lightcraft_raster::Rgba8 { + width: c.size[0], + height: c.size[1], + data: c.pixels.iter().map(|p| p.to_array()).collect(), + }; + let tex = |h: &Headless, s: Slot| rgba(h.app.renderer.textures.get(&s).and_then(|t| t.pixels.clone()).as_deref().unwrap()); + let thumb = thumb_slot(&h).unwrap(); + let (main0, thumb0) = (tex(&h, Slot::Main), tex(&h, thumb)); + let hist0 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); + + // the command loads it (and keeps it as the setting); textures are made again + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": p3}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["kind"], "matrix"); + assert_eq!(h.app.ui.settings.display_profile, p3); + let d = h.app.renderer.display().cloned().unwrap(); + assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); + h.settle(SETTLE); + let (main1, thumb1) = (tex(&h, Slot::Main), tex(&h, thumb)); + let mut want = thumb0.clone(); + d.profile.from_srgb(&mut want).unwrap(); + let max_diff = |a: &lightcraft_raster::Rgba8, b: &lightcraft_raster::Rgba8| { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..3).map(move |k| (p[k] as i32 - q[k] as i32).abs())).max().unwrap_or(0) + }; + assert_eq!((thumb1.width, thumb1.height), (thumb0.width, thumb0.height)); + assert!(max_diff(&thumb1, &want) <= 1, "thumbnails are converted from sRGB: {}", max_diff(&thumb1, &want)); + assert_ne!(thumb1, thumb0); + // the loupe: rendered for the display; colours sRGB holds look as they did + assert_eq!((main1.width, main1.height), (main0.width, main0.height)); + assert_ne!(main1, main0); + let mut main_want = main0.clone(); + d.profile.from_srgb(&mut main_want).unwrap(); + let mean = main1.data.iter().zip(&main_want.data).map(|(p, q)| (0..3).map(|k| (p[k] as f64 - q[k] as f64).abs()).sum::()).sum::() + / (3 * main1.data.len()) as f64; + assert!(mean < 1.5, "{mean}"); + let hist1 = h.app.renderer.textures[&Slot::Main].histogram.clone().unwrap(); + assert_eq!(hist1.total, hist0.total, "the histogram stays sRGB"); + + // a bad file: an error, nothing changes + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": bad}}), t); + assert_eq!(r["ok"], false, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(d.id())); + assert_eq!(h.app.ui.settings.display_profile, p3); + // …chosen as the setting (Settings ▸ Display), it is reported and previews go back to sRGB + h.app.ui.settings.display_profile = bad.clone(); + h.step(); + assert!(h.app.display_error.as_deref().is_some_and(|e| e.contains("not a valid ICC profile")), "{:?}", h.app.display_error); + assert_eq!(h.app.renderer.display_id(), None); + // the Display tab shows it + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "display"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + // none: sRGB again + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["ok"], true, "{r}"); + assert!(r["result"]["path"].is_null()); + assert!(h.app.ui.settings.display_profile.is_empty() && h.app.display_error.is_none()); + assert!(h.step_until(SETTLE, |h| ready(h) && h.app.renderer.textures.contains_key(&thumb))); + h.settle(SETTLE); + assert_eq!(tex(&h, thumb), thumb0, "back to the sRGB thumbnail"); + let _ = std::fs::remove_dir_all(&dir); + } + /// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through /// the engine; the delete confirmation guards ⌫. #[test] @@ -2650,7 +2741,7 @@ mod tests { let r = h.request("ui.key", json!({"key": ",", "cmd": true}), t); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: "general".into() })); - for tab in ["import", "performance", "interface", "general"] { + for tab in ["import", "performance", "display", "interface", "general"] { let r = h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); assert_eq!(r["ok"], true, "{tab}: {r}"); assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::Settings { tab: tab.into() })); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 705818911..7a8f20679 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -115,6 +115,8 @@ pub struct Services { pub pick_preset_files: Option, /// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…). pub pick_tracklog: Option, + /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). + pub pick_display_profile: Option, /// Save dialog for an exported `.lcpreset` file. pub save_preset_file: Option, /// Open dialog for point-curve preset files (`.lccurve`). @@ -259,6 +261,10 @@ pub struct LightcraftApp { gpu_applied: Option, /// The memory budget setting last applied (MB, 0 = automatic). memory_applied: Option, + /// The display profile setting last applied (`app.displayProfile`). + pub(crate) display_applied: Option, + /// Why the display profile setting could not be applied (shown in Settings ▸ Display). + pub display_error: Option, /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, @@ -320,6 +326,8 @@ impl LightcraftApp { window_is_fullscreen: false, gpu_applied: None, memory_applied: None, + display_applied: None, + display_error: None, library_problem: None, model_setup: Default::default(), } @@ -837,6 +845,24 @@ impl LightcraftApp { let _ = self.session.execute("app.memoryBudget", &serde_json::json!({"mb": mb})); } self.memory_applied = Some(mb); + // the monitor profile (Settings ▸ Display): previews follow it from the next frame + if self.display_applied.as_ref() != Some(&self.ui.settings.display_profile) { + let path = self.ui.settings.display_profile.clone(); + match lightcraft_engine::display::Display::load_opt(&path) { + Ok(d) => { + self.renderer.set_display(d); + self.display_error = None; + } + Err(msg) => { + // a profile that can't be used: previews are sRGB, the setting is kept + self.renderer.set_display(None); + log::warn!("display profile: {msg}"); + self.toast_error(ctx, format!("{}: {msg}", crate::i18n::tr("Display profile not used"))); + self.display_error = Some(msg); + } + } + self.display_applied = Some(path); + } if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { self.window_is_fullscreen = fs; } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5b79548b9..1c95597fd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -169,6 +169,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.openLibrary", "Open Library…", None, "File"), + // Settings ▸ Display + ("app.displayProfile", "Display Profile", None, ""), ("app.about", "About LightCraft", None, "Help"), ("app.systemInfo", "System Info…", None, "Help"), ("app.openLogFolder", "Open Log Folder", None, "Help"), @@ -472,6 +474,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option crate::panels::settings::open_library(app, p), + "app.displayProfile" => crate::panels::settings::display_profile(app, p), "file.backupLibrary" | "file.restoreLibrary" => { let action = if id == "file.backupLibrary" { app.services.backup_library.as_mut() } else { app.services.restore_library.as_mut() }; match action { diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index d3b53611d..3849a191b 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -225,7 +225,13 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { match t.rx.try_recv() { Ok(Ok(out)) => { let Some(t) = app.merge.preview_task.take() else { return }; - if let Some(img) = out.preview { + if let Some(mut img) = out.preview { + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("display profile: {e}"); + } let color = Arc::new(egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes())); let tex = ctx.load_texture("merge-preview", color.clone(), egui::TextureOptions::LINEAR); app.merge.preview_pixels = Some(color); diff --git a/crates/ui-egui/src/panels/second.rs b/crates/ui-egui/src/panels/second.rs index ac239bcdd..79aa31d1d 100644 --- a/crates/ui-egui/src/panels/second.rs +++ b/crates/ui-egui/src/panels/second.rs @@ -39,7 +39,7 @@ fn body(app: &mut LightcraftApp, ui: &mut egui::Ui) { let ppp = ui.ctx().pixels_per_point(); let side = super::detail::texture_side(ui.ctx()).clamp(64, 4096); let (w, h) = (((area.width() * ppp) as usize).clamp(64, side), ((area.height() * ppp) as usize).clamp(64, side)); - if let Some(job) = app.session.loupe_job(id, w, h, true) + if let Some(job) = app.session.loupe_job(id, w, h, true).map(|j| app.renderer.job_for(Slot::Second, j)) && app.renderer.textures.get(&Slot::Second).is_none_or(|t| t.key != job.key) && !app.renderer.is_pending(Slot::Second) { diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 2774a9622..7036e10f1 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -1,8 +1,9 @@ -//! The Settings dialog (⌘,): General, Import, Performance, Interface, Faces, AI Denoise. +//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Faces, AI Denoise. //! //! Changes apply immediately (no OK/Cancel). Where they are stored: //! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, -//! preview size, filmstrip/grid badges, last library) and Auto Advance live in the UI state, +//! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in +//! the UI state, //! saved by the host in its config folder (`ui.json`); //! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the //! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`, @@ -21,6 +22,7 @@ pub const TABS: &[(&str, &str)] = &[ ("general", "General"), ("import", "Import"), ("performance", "Performance"), + ("display", "Display"), ("interface", "Interface"), ("faces", "Faces"), ("denoise", "AI Denoise"), @@ -49,6 +51,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { match tab.as_str() { "import" => import_tab(app, ui, &t), "performance" => performance_tab(app, ui, &t), + "display" => display_tab(app, ui, &t), "interface" => interface_tab(app, ui, &t), "faces" => super::faces::settings_tab(app, ui, &t), "denoise" => super::denoise::settings_tab(app, ui, &t), @@ -532,6 +535,69 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { // ---------------------------------------------------------------------------------- Interface +/// The monitor profile (`app.displayProfile`): previews are shown through the display's ICC +/// profile, so wide-gamut and calibrated displays show colours as they are. +fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Monitor profile")); + let current = app.renderer.display().cloned(); + row(ui, t, crate::i18n::tr("Profile"), |ui| { + let name = match ¤t { + Some(d) => d.profile.description.clone(), + None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(), + }; + ui.label(RichText::new(name).color(t.text)); + }); + row(ui, t, "", |ui| { + let can = app.services.pick_display_profile.is_some(); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Choose Profile…"))); + register(ui.ctx(), "button:settingsDisplayProfile", r.rect); + if r.clicked() + && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) + { + app.ui.settings.display_profile = path; + } + let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(crate::i18n::tr("Use sRGB"))); + register(ui.ctx(), "button:settingsDisplayProfileNone", r.rect); + if r.clicked() { + app.ui.settings.display_profile.clear(); + } + if !can { + hint(ui, t, crate::i18n::tr("not available here")); + } + }); + if let Some(e) = &app.display_error { + ui.label(RichText::new(e).size(11.0).color(t.reject)); + } + if let Some(d) = ¤t { + let info = d.describe(); + row(ui, t, crate::i18n::tr("File"), |ui| { + ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim)); + }); + row(ui, t, crate::i18n::tr("Type"), |ui| { + let kind = match d.profile.kind { + lightcraft_engine::display::DisplayKind::MatrixTrc => crate::i18n::tr("Matrix/TRC"), + lightcraft_engine::display::DisplayKind::Lut => crate::i18n::tr("LUT-based"), + }; + ui.label(RichText::new(kind).color(t.text)); + }); + row(ui, t, crate::i18n::tr("Primaries (x, y)"), |ui| { + let p = &info["primaries"]; + let xy = |(k, l): (&str, &str)| format!("{l} {:.3}, {:.3}", p[k][0].as_f64().unwrap_or(0.0), p[k][1].as_f64().unwrap_or(0.0)); + let text = [("red", "R"), ("green", "G"), ("blue", "B")].map(xy).join(" "); + ui.label(RichText::new(text).size(11.0).color(t.text_dim)); + }); + } + hint( + ui, + t, + crate::i18n::tr( + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.", + ), + ); + #[cfg(target_os = "linux")] + hint(ui, t, crate::i18n::tr("Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.")); +} + fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Filmstrip")); check(ui, "settings.filmNames", &mut app.ui.settings.film_names, "Show file names"); @@ -584,3 +650,29 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result app.ui.settings.library_path = path.clone(); Ok(json!({"path": path, "photos": app.session.catalog.len()})) } + +// ---------------------------------------------------------------------------- Display profile + +/// `app.displayProfile {path?: string | null}`: show previews through this monitor ICC profile +/// (`""` or `null`: none, the display is treated as sRGB) and keep it as the setting; no `path`: +/// report only. A file that can't be used is an error and changes nothing. Returns the profile in +/// use: `{path, description, kind (matrix|lut), primaries {red, green, blue: [x, y]}}`. +pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { + let path = match p.get("path") { + None => None, + Some(Value::Null) => Some(String::new()), + Some(Value::String(s)) => Some(s.trim().to_string()), + Some(_) => return Err("app.displayProfile: path must be a string or null".into()), + }; + if let Some(path) = path { + let d = lightcraft_engine::display::Display::load_opt(&path).map_err(|e| format!("app.displayProfile: {e}"))?; + app.renderer.set_display(d); + app.ui.settings.display_profile = path.clone(); + app.display_applied = Some(path); + app.display_error = None; + } + Ok(match app.renderer.display() { + Some(d) => d.describe(), + None => json!({"path": null, "description": "sRGB (no display profile)"}), + }) +} diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index f46436941..2a637e20a 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -211,6 +211,8 @@ pub struct Renderer { /// A budget other than the memory budget's share (tests). #[cfg(test)] pub(crate) stage_budget_override: Option, + /// The monitor profile textures are made for (`app.displayProfile`; `None`: sRGB). + display: Option>, /// Since when nothing has been pending, and whether the GPU pool was trimmed since. #[cfg(not(target_arch = "wasm32"))] idle: Option<(std::time::Instant, bool)>, @@ -268,6 +270,7 @@ impl Renderer { stages_trimmed: 0, #[cfg(test)] stage_budget_override: None, + display: None, #[cfg(not(target_arch = "wasm32"))] idle: None, } @@ -304,6 +307,46 @@ impl Renderer { self.thumb_inputs.insert(photo.id, (Arc::downgrade(photo), bucket, key, quick)); } + /// Views that render straight into the display profile's primaries (wide gamut); everything + /// else is rendered for sRGB and converted ([`lightcraft_engine::display::present`]). + fn renders_for_display(slot: Slot) -> bool { + matches!(slot, Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::Second) + } + + /// `job` as [`Self::request`] would run it for `slot` (its key included): views render for + /// the display profile in use. + pub fn job_for(&self, slot: Slot, job: RenderJob) -> RenderJob { + if Self::renders_for_display(slot) { job.with_display(self.display.clone()) } else { job } + } + + /// The display profile in use (id). + pub fn display_id(&self) -> Option { + self.display.as_ref().map(|d| d.id()) + } + + /// The display profile textures are made for (`None`: sRGB). + pub fn display(&self) -> Option<&Arc> { + self.display.as_ref() + } + + /// Show previews through `display` from now on (`app.displayProfile`): when it changes, every + /// texture is made again for the new one. + pub fn set_display(&mut self, display: Option>) { + if display.as_ref().map(|d| d.id()) != self.display_id() { + self.forget_all(); + } + self.display = display; + } + + /// Run `job` and make its image ready for `display` (on the worker thread). + fn run_for(job: impl FnOnce() -> RenderResult, display: &Option>) -> RenderResult { + let mut r = job(); + if let Some(d) = display { + lightcraft_engine::display::present(&mut r, d); + } + r + } + fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { // A matching old texture must not mask a different newer request (e.g. undo). if let Some(&(k, p)) = self.pending.get(&slot) { @@ -340,7 +383,8 @@ impl Renderer { } /// Request a render for `slot` (no-op if already current or pending at the same priority). - pub fn request(&mut self, slot: Slot, mut job: RenderJob, priority: u32) { + pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) { + let mut job = self.job_for(slot, job); if !self.request_needed(slot, job.key, priority) { return; } @@ -358,11 +402,15 @@ impl Renderer { } let key = job.key; let background = matches!(slot, Slot::Thumb(_) | Slot::ThumbQuick(_) | Slot::Prefetch(_)); + let display = self.display.clone(); self.pool.submit( slot, key, priority, - Box::new(move || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() }), + Box::new(move || { + let run = || if background { lightcraft_engine::memory::in_background(|| job.run()) } else { job.run() }; + Self::run_for(run, &display) + }), ); } @@ -376,7 +424,8 @@ impl Renderer { job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || job.run())); + let display = self.display.clone(); + self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display))); } /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one @@ -563,8 +612,12 @@ impl Renderer { } self.dispatch(); let mut changed = false; - for (mut slot, r, ms) in finished { + for (mut slot, mut r, ms) in finished { self.completed += 1; + // made for another display profile than the one in use: obsolete + if r.display.is_some_and(|id| Some(id) != self.display_id()) { + continue; + } // Ignore obsolete pixels, failures and decoded sources, even for the same render key; // an interactive view still takes a superseded draft that is newer than what it // shows, and the source it decoded: a drag supersedes every request before it @@ -586,6 +639,10 @@ impl Renderer { if slot.is_view() { self.shown_ids.insert(slot, r.request_id); } + // (results from an offload, which doesn't know the display profile: converted here) + if let Some(d) = &self.display { + lightcraft_engine::display::present(&mut r, d); + } if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 83b3df292..532d03885 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -156,6 +156,9 @@ pub struct AppSettings { pub grid_badges: GridBadges, /// Shortcuts the user changed (Help ▸ Keyboard Shortcuts): command id → shortcut, `""` = none. pub keymap: crate::shortcuts::Keymap, + /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: + /// the display is treated as sRGB). + pub display_profile: String, } impl Default for AppSettings { @@ -172,6 +175,7 @@ impl Default for AppSettings { film_badges: true, grid_badges: GridBadges::Auto, keymap: Default::default(), + display_profile: String::new(), } } } diff --git a/docs/display-profiles.md b/docs/display-profiles.md new file mode 100644 index 000000000..63c557108 --- /dev/null +++ b/docs/display-profiles.md @@ -0,0 +1,44 @@ +# Display (monitor) profiles + +LightCraft can show photos through the monitor's ICC profile, so colours are right on wide-gamut and calibrated +displays. Without one, previews are sRGB, which a wide-gamut panel shows oversaturated. + +## Using it + +Settings ▸ Display ▸ **Choose Profile…** picks an `.icc` / `.icm` file (the dialog opens where the system keeps +them: `~/.local/share/icc` on Linux, ColorSync's folders on macOS, `…\spool\drivers\color` on Windows). **Use sRGB** +goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) and applies at once; a file that +can't be used is reported there and previews stay sRGB. + +Agents and scripts: `app.displayProfile {"path": "/path/to/monitor.icc"}` (`""` or `null`: none; no `path`: report +only) returns `{path, description, kind: "matrix" | "lut", primaries: {red, green, blue: [x, y]}}`. + +## How it works + +- **The loupe renders into the display's gamut.** Views (Detail, Before, Compare / Reference, preset and profile + hover, the second window) ask the pipeline for a render in the display's own primaries + (`lightcraft_pipeline::DisplaySpace`, `RenderRequest::display`): the per-pixel stage converts from the working space + to the display's linear RGB, gamut maps into *its* gamut and encodes with the sRGB curve, on the CPU and the GPU + alike (only `FinishParams::to_out` / `out_luma` change). Colours an sRGB render would clip stay as saturated as the + display can show. For a display that is exactly a standard space the result equals an 8-bit render into that space. +- **Then to the display's device values** (`lightcraft_codecs::display::DisplayProfile::correct`): for a matrix/TRC + profile, three 256-entry tables from the sRGB curve to the profile's tone curves (nothing at all when they are the + sRGB curve, as for Display P3); for a LUT-based profile, a `moxcms` transform through its B2A tables. A LUT-only + profile (no colorant tags) gets its primaries measured through its A2B tables. +- **Everything made for sRGB is converted** (`lightcraft_engine::display::present`, on the render workers): + thumbnails, cached view previews, embedded camera JPEGs and merge previews. +- **What stays sRGB:** the histogram (of the same sampled pixels, converted back to sRGB), the rendered-thumbnail and + view-preview caches (a profile change doesn't invalidate them), exports, and the UI itself. +- **Soft proofing:** the blue "display" gamut warning shows what the monitor can't show, from its profile. +- Conversions are relative colorimetric: the display's white is the image's white. + +## Cost + +Measured on a 2-core cloud VM, 2560 × 1707 preview: the tone-curve tables take about 5 ms (single-threaded); converting the sampled +histogram pixels a few ms; the sRGB copy for the view-preview cache (full-quality renders only) about 30 ms; a +LUT-based profile's correction about 65 ms. Drafts during slider drags skip the full sRGB copy. + +## Not yet + +- Picking up the profile the operating system assigns (X11 `_ICC_PROFILE`, colord, ColorSync, WCS) and following + the window across monitors with different profiles. diff --git a/docs/parity.md b/docs/parity.md index 4c27755b5..a857cabec 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -42,15 +42,15 @@ and prints the summary; `cargo xtask parity --write` refreshes the summary table | S. Export (EXP) | 15 | 1 | 2 | 0 | 7/7 (100%) | 7/7 (100%) | | T. Share (SHARE) | 0 | 0 | 0 | 4 | — | — | | U. Map & location (MAP) | 0 | 1 | 1 | 0 | — | 0/1 (0%) | -| V. Preferences (PREF) | 6 | 1 | 2 | 2 | 1/1 (100%) | 4/4 (100%) | +| V. Preferences (PREF) | 7 | 1 | 2 | 2 | 1/1 (100%) | 5/5 (100%) | | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 18 | 5 | 1 | 1 | 8/8 (100%) | 6/8 (75%) | | Y. Menus | 82 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | | Z. Keyboard shortcuts (desktop) | 75 | 3 | 3 | 1 | 49/52 (94%) | 23/24 (96%) | | Lightroom Classic extras | 32 | 11 | 39 | 9 | — | 22/24 (92%) | -| **Total** | 394 | 38 | 84 | 36 | 193/200 (97%) | 142/152 (93%) | +| **Total** | 395 | 38 | 84 | 36 | 193/200 (97%) | 143/153 (93%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.0%** of 516 in-scope rows — P0 98.2% of 200 · P1 95.7% of 152 · P2 42.9% of 163. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **80.1%** of 517 in-scope rows — P0 98.2% of 200 · P1 95.8% of 153 · P2 42.9% of 163. ## Top gaps @@ -483,10 +483,11 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-PREF-GENERAL | General settings | P0 | ✅ | `cmd:app.settings`, `cmd:app.openLibrary`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/notices.rs` | General / Import / Performance / Interface tabs; app settings in ui.json (written atomically, saved when the library changes and every few seconds), library settings in prefs.json; a damaged settings file is kept as `.corrupt-