LR-PREF-DISPLAY: monitor ICC profiles (display colour management)

Previews were always sRGB, which wide-gamut monitors show oversaturated.
Settings > Display > Choose Profile... (and the UI command
app.displayProfile) now shows photos through the monitor's ICC profile,
matrix/TRC or LUT-based.

- codecs: DisplayProfile (moxcms): the display's primaries (colorant tags,
  or measured through the A2B tables of a LUT-only profile), the proxy ->
  device correction (per-channel curve tables for matrix/TRC profiles, a
  CMS transform for LUT profiles) and sRGB <-> display transforms.
- pipeline: DisplaySpace / RenderRequest::display: the per-pixel stage
  renders into the display's primaries and gamut maps into its gamut
  (CPU and GPU: only to_out / out_luma change). Soft proofing's display
  gamut warning uses the display's gamut.
- engine: RenderJob::with_display; display::present converts sRGB
  results (thumbnails, cached previews, embedded JPEGs). Histograms and
  the preview caches stay sRGB; exports are unchanged.
- ui: the loupe, Before, Compare, hover and second window render for the
  display; everything else is converted on the render workers. Settings >
  Display tab, ui.json setting, file picker opening in the system's ICC
  folder.

Tests: profile parsing and transforms (sRGB, P3, gamma 2.2, a synthetic
LUT-only profile, corrupted files), display renders equal 8-bit renders
into the matching output space, sampled histograms, and a headless UI test
of the whole flow. docs/display-profiles.md describes the design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEuxxPyG8t6KcADbZSEAUj
This commit is contained in:
kelanna
2026-10-09 11:21:09 -07:00
co-authored by Claude Opus 5.5
parent 88ea1a804b
commit 3bcc265bf6
26 changed files with 1169 additions and 24 deletions
+1
View File
@@ -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,
}
}
+3
View File
@@ -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;
+26
View File
@@ -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<std::path::PathBuf> {
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<Option<std::path::PathBuf>> = 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<std::path::PathBuf> {
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"))
+442
View File
@@ -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<Transform8BitExecutor>),
}
/// 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<Transform8BitExecutor>,
to_srgb: Arc<Transform8BitExecutor>,
}
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<Mat3> {
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<T>(f: impl FnOnce() -> Option<T>) -> Option<T> {
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<DisplayProfile, String> {
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<Rgba8, String> {
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<Mat3, String> {
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<Transform8BitExecutor>, 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<u8> {
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<f32>| 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);
}
}
+1 -1
View File
@@ -205,7 +205,7 @@ pub fn write_named(n: NamedSpace) -> Vec<u8> {
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]);
+1
View File
@@ -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;
+176
View File
@@ -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<Display, String> {
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<Display, String> {
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<Option<Arc<Display>>, 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<Rgba8>, Option<Histogram>) {
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);
}
}
+1
View File
@@ -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)),
+1
View File
@@ -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"))]
+45 -2
View File
@@ -674,6 +674,8 @@ pub struct RenderJob {
pub stages: Option<Arc<StageCache>>,
/// Keep a full-quality result here as the photo's view preview ([`crate::Session::loupe_job`]).
pub view_cache: Option<(Arc<PreviewCache>, Hash128)>,
/// Render for this monitor profile ([`Self::with_display`]).
pub display: Option<Arc<crate::display::Display>>,
}
pub struct RenderResult {
@@ -687,6 +689,9 @@ pub struct RenderResult {
pub loaded: Option<DecodedSource>,
/// Set for a [`QuickJob`]'s stand-in: where the image came from.
pub quick: Option<QuickSource>,
/// The display profile (id) the image is encoded for; `None`: sRGB (see
/// [`crate::display::present`]).
pub display: Option<u64>,
}
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<Arc<crate::display::Display>>) -> Self {
if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 {
return self;
}
let k = |d: &Option<Arc<crate::display::Display>>| 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,
})
}
+4 -1
View File
@@ -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(),
+21 -1
View File
@@ -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<crate::Proof>) {
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<crate::Proof>) -> Rgba8 {
pub(crate) fn finish(
p: &Prepared,
s: &DevelopSettings,
frame: &Frame,
info: &SourceInfo,
space: OutputSpace,
display: Option<&crate::DisplaySpace>,
proof: Option<crate::Proof>,
) -> 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],
+6 -2
View File
@@ -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<PixelWindow>,
/// 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<DisplaySpace>,
}
/// 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),
+57 -3
View File
@@ -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<DisplaySpace> {
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::<f64>() - 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> {
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.
+40
View File
@@ -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;
+92 -1
View File
@@ -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::<f64>()).sum::<f64>()
/ (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() }));
+26
View File
@@ -115,6 +115,8 @@ pub struct Services {
pub pick_preset_files: Option<PickFiles>,
/// Open dialog for a GPS track log (`.gpx`; Photo ▸ Auto-Tag from Tracklog…).
pub pick_tracklog: Option<PickFiles>,
/// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only).
pub pick_display_profile: Option<PickFiles>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for point-curve preset files (`.lccurve`).
@@ -259,6 +261,10 @@ pub struct LightcraftApp {
gpu_applied: Option<bool>,
/// The memory budget setting last applied (MB, 0 = automatic).
memory_applied: Option<u32>,
/// The display profile setting last applied (`app.displayProfile`).
pub(crate) display_applied: Option<String>,
/// Why the display profile setting could not be applied (shown in Settings ▸ Display).
pub display_error: Option<String>,
/// 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<panels::library_problem::LibraryProblem>,
@@ -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;
}
+3
View File
@@ -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<Re
Ok(Value::Null)
}
"app.openLibrary" => 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 {
+7 -1
View File
@@ -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);
+1 -1
View File
@@ -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)
{
+94 -2
View File
@@ -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 &current {
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) = &current {
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<Value, String>
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<Value, String> {
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)"}),
})
}
+61 -4
View File
@@ -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<usize>,
/// The monitor profile textures are made for (`app.displayProfile`; `None`: sRGB).
display: Option<Arc<lightcraft_engine::display::Display>>,
/// 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<u64> {
self.display.as_ref().map(|d| d.id())
}
/// The display profile textures are made for (`None`: sRGB).
pub fn display(&self) -> Option<&Arc<lightcraft_engine::display::Display>> {
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<Arc<lightcraft_engine::display::Display>>) {
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<Arc<lightcraft_engine::display::Display>>) -> 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);
}
+4
View File
@@ -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(),
}
}
}
+44
View File
@@ -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.
+6 -5
View File
@@ -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.
<!-- /parity:summary -->
## 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-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks |
| 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 / Display / 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-<time>` and reported, an unreadable one is not overwritten that session (`library.info` → `settingsWarnings`); quitting with unsaved changes retries, then asks |
| LR-PREF-LOCALSTORAGE | Storage & cache | P1 | ✅ | `cmd:library.preferences`, `cmd:library.clearPreviews`, `cmd:library.compact` | thumbnail cache size + clear in Settings → Performance; library location + Open Library… in General |
| LR-PREF-ACCOUNT | Account | OOS | 🚫 | | |
| LR-PREF-INTERFACE | Interface options | P1 | ✅ | `cmd:app.settings` | filmstrip names/badges, grid badges (auto/always/never), square-grid names, navigator, info overlay |
| LR-PREF-DISPLAY | Monitor (display) colour management | P1 | ✅ | `cmd:app.displayProfile`, `crates/codecs/src/display.rs`, `crates/engine/src/display.rs`, `crates/ui-egui/src/panels/settings.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand; the loupe, Before, Compare, hover previews and the second window render into the display's primaries (its full gamut, CPU and GPU), thumbnails and stand-ins are converted from sRGB; histogram, preview caches and exports stay sRGB; soft proofing's display gamut warning uses the display's gamut. Missing: picking up the profile the OS assigns (X11 `_ICC_PROFILE`, colord, ColorSync, WCS), per-monitor profiles |
| LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off, preview size (Automatic = the whole-frame loupe render at canvas size, up to 2560 px; or a 1600–5120 px size for it; zoomed views add a window at up to 100 %, issue #323), memory budget (a quarter of it is the idle per-view stage caches' share; the open photo's views are counted and reported, and cut only past four times that, the before side's and the zoom window's first — a Before/After at 1:1 on a 2800 px canvas holds about 1 GB of GPU stages, plus the one 288 MB copy of the original, which `ui.inspect` reports as `sharedSourceBytes`), thumbnail cache size in Settings |
| LR-PREF-PEOPLE | Face recognition | P2 | 🟡 | `cmd:faces.enable`, `cmd:faces.models.list`, `cmd:faces.models.inspect`, `cmd:faces.models.install`, `cmd:faces.models.download`, `cmd:faces.models.downloads`, `cmd:faces.models.downloadCancel`, `cmd:faces.models.remove`, `cmd:faces.models.select`, `cmd:dialog.faceModel`, `crates/faces/src/lib.rs`, `crates/ui-egui/src/panels/faces.rs` | Settings ▸ Faces: an off-by-default toggle and the face model list. Models are opt-in and never bundled, the 232 KB YuNet detector included. Press Download (terms first; then a pinned address, size and SHA-256 checked, fetched over pure-Rust https with `lightcraft-fetch`), or add any `.onnx` by picking or dropping it: it is recognised by hash or described from its shape, shows its licence terms and training-data note, runs a self-test, and installs only after an "I accept" box; an installed model is chosen and recognition switched on at once (the newest wins, each model keeps its own embedding cache). Non-commercial models are add-by-file only, never offered for download. Suggestions of names run on the shared pure-Rust CPU interpreter (`crates/faces/src/net.rs`, `crates/faces/src/runtime.rs`; bounded im2col/GEMM, checked ONNX shapes/operators; no external inference runtime; 40 public crops agree within 2.1e-6, one-thread SFace 34–36 ms / AuraFace 233–252 ms, see docs/faces.md) in a background worker and are never applied automatically. Thresholds are starting points calibrated on busts, not yet on live faces
| LR-PREF-WATERMARK | Watermark settings | P1 | ✅ | `cmd:export.savePreset`, `crates/ui-egui/src/panels/dialogs.rs` | set in the Export dialog; kept with Export with Previous and in saved export presets |
@@ -763,7 +764,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06-
| LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW |
| LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions |
| LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history |
| LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings, Create Proof Copy (a named virtual copy). Missing: printer ICC profiles, rendering intent, Simulate Paper & Ink |
| LRC-DEV-SOFTPROOF | Soft proofing | P2 | 🟡 | `cmd:view.softProof`, `crates/pipeline/src/output.rs` (`Proof`), `crates/ui-egui/src/panels/edit.rs` (`soft_proofing`) | S in the loupe (in grids S stays Expand/Collapse Stack): paper-white surround and "Proof Preview", proof profile (sRGB / Display P3 / Adobe RGB / ProPhoto / Rec. 2020), destination (red) and display (blue) gamut warnings (the display's gamut is the monitor profile's, `cmd:app.displayProfile`, else sRGB), Create Proof Copy (a named virtual copy). Missing: printer ICC profiles, rendering intent, Simulate Paper & Ink |
| LRC-DEV-AUTOSYNC | Sync / auto sync / paste previous | P1 | ✅ | `cmd:develop.sync`, `cmd:develop.autoSync`, `cmd:develop.pastePrevious` | Edit ▸ Sync Settings / Auto Sync (⌥⇧⌘A): only the changed settings carry over, one undo step, slider drags sync on release, spots / red eye stay per photo; Edit panel banner |
| LRC-DEV-MATCHEXP | Match total exposures | P2 | ✅ | `cmd:develop.matchExposure` | Photo ▸ Match Total Exposures: the selected photos' Exposure set so shutter × ISO ÷ aperture² plus the slider matches the active photo's |
| LRC-DEV-CALIB | Calibration panel | P1 | ✅ | `ctl:calibration.*` | |
+6
View File
@@ -2,6 +2,12 @@
## October 2026
### Monitor profiles
- Settings ▸ Display ▸ Choose Profile… shows photos through your monitor's ICC profile (matrix/TRC or LUT-based), so
colours are right on wide-gamut and calibrated displays instead of oversaturated. The Detail view renders straight
into the display's own gamut; thumbnails and other previews are converted to it. Histograms, the preview caches
and exports don't change. Agents use `app.displayProfile`. See [display profiles](display-profiles.md).
### Masking
- Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated
ellipses and radial components in compound masks. Edge and rotation handles keep their existing