mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 04:57:22 -06:00
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:
co-authored by
Claude Opus 5.5
parent
88ea1a804b
commit
3bcc265bf6
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() }));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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<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)"}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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.*` | |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user