diff --git a/apps/lightcraft/src/colord.rs b/apps/lightcraft/src/colord.rs new file mode 100644 index 00000000..70f7fbdd --- /dev/null +++ b/apps/lightcraft/src/colord.rs @@ -0,0 +1,125 @@ +//! The ICC profile the desktop assigns to the display (Linux, through colord). +//! +//! GNOME, KDE and most other desktops keep each monitor's profile in colord +//! (`org.freedesktop.ColorManager` on the system bus): Settings ▸ Color writes it there, and a +//! calibrator (DisplayCAL, gnome-color-manager) installs its profile there. While no monitor profile +//! is chosen in Settings ▸ Display, previews are shown through that profile +//! (`Services::screen_profile`). A background thread asks colord every few seconds, so the UI never +//! waits on D-Bus; without colord, or a display with no profile, the previews stay sRGB. +//! +//! With several displays, the window's own one isn't known here (eframe doesn't say which monitor a +//! window is on), so the profile is used only when every display that has one has the same: a +//! mixed set stays sRGB rather than guess. + +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +/// How often colord is asked again (a profile change in the desktop's settings shows within this). +const POLL: Duration = Duration::from_secs(3); +/// A wait before trying again when colord isn't there (not installed, no system bus). +const RETRY: Duration = Duration::from_secs(30); +/// The largest profile file read (real display profiles are a few KiB to a few MiB). +const MAX_PROFILE: u64 = 16 << 20; + +const SERVICE: &str = "org.freedesktop.ColorManager"; +const DEVICE: &str = "org.freedesktop.ColorManager.Device"; +const PROFILE: &str = "org.freedesktop.ColorManager.Profile"; + +/// Start the background reader and return the service the UI polls (`Services::screen_profile`). +pub fn service() -> Box Option>> { + let shared: Arc>>> = Arc::new(Mutex::new(None)); + let writer = Arc::clone(&shared); + let spawned = std::thread::Builder::new().name("colord".into()).spawn(move || { + loop { + let wait = match connect() { + Some(conn) => { + let icc = read(&conn); + *writer.lock().unwrap_or_else(PoisonError::into_inner) = icc; + POLL + } + None => RETRY, + }; + std::thread::sleep(wait); + // the app is gone: nobody reads the profile any more + if Arc::strong_count(&writer) == 1 { + return; + } + } + }); + if let Err(e) = spawned { + log::warn!("screen profile: no colord reader: {e}"); + } + Box::new(move || shared.lock().unwrap_or_else(PoisonError::into_inner).clone()) +} + +fn connect() -> Option { + zbus::blocking::connection::Builder::system().ok()?.method_timeout(Duration::from_secs(2)).build().ok() +} + +/// The profile colord assigns to the displays, as file bytes (see the module notes for several displays). +fn read(conn: &zbus::blocking::Connection) -> Option> { + let manager = zbus::blocking::Proxy::new(conn, SERVICE, "/org/freedesktop/ColorManager", SERVICE).ok()?; + let devices: Vec = manager.call("GetDevicesByKind", &("display",)).ok()?; + let mut files = Vec::new(); + for device in devices.iter().take(64) { + let Some(proxy) = zbus::blocking::Proxy::new(conn, SERVICE, device.as_str(), DEVICE).ok() else { continue }; + let profiles: Vec = proxy.get_property("Profiles").unwrap_or_default(); + // colord lists a device's profiles default first + let Some(first) = profiles.first() else { continue }; + let Some(profile) = zbus::blocking::Proxy::new(conn, SERVICE, first.as_str(), PROFILE).ok() else { continue }; + if let Ok(file) = profile.get_property::("Filename") { + files.push(file); + } + } + let file = pick(&files)?; + load(std::path::Path::new(file)) +} + +/// The profile to use for these displays' profile files: the one they share, `None` with none or +/// with different ones (empty names are ignored). +fn pick(files: &[String]) -> Option<&str> { + let mut named = files.iter().map(String::as_str).filter(|f| !f.is_empty()); + let first = named.next()?; + named.all(|f| f == first).then_some(first) +} + +/// The bytes of profile file `path`, `None` when it can't be read or is implausibly large. +fn load(path: &std::path::Path) -> Option> { + use std::io::Read as _; + let file = std::fs::File::open(path).ok()?; + let mut bytes = Vec::new(); + file.take(MAX_PROFILE + 1).read_to_end(&mut bytes).ok()?; + (!bytes.is_empty() && bytes.len() as u64 <= MAX_PROFILE).then_some(bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_shared_profile_is_used_and_a_mixed_set_is_not() { + let s = |v: &[&str]| v.iter().map(|f| f.to_string()).collect::>(); + assert_eq!(pick(&s(&[])), None); + assert_eq!(pick(&s(&["/a.icc"])), Some("/a.icc")); + assert_eq!(pick(&s(&["/a.icc", "", "/a.icc"])), Some("/a.icc")); + assert_eq!(pick(&s(&["/a.icc", "/b.icc"])), None, "which one the window is on isn't known"); + assert_eq!(pick(&s(&[""])), None); + } + + #[test] + fn profile_files_are_read_with_a_size_cap() { + let dir = std::env::temp_dir().join(format!("colord-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let small = dir.join("small.icc"); + std::fs::write(&small, b"acsp").unwrap(); + assert_eq!(load(&small).as_deref(), Some(&b"acsp"[..])); + let empty = dir.join("empty.icc"); + std::fs::write(&empty, b"").unwrap(); + assert_eq!(load(&empty), None); + assert_eq!(load(&dir.join("missing.icc")), None); + let big = dir.join("big.icc"); + std::fs::File::create(&big).unwrap().set_len(MAX_PROFILE + 1).unwrap(); + assert_eq!(load(&big), None); + std::fs::remove_dir_all(&dir).unwrap(); + } +} diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index c9b9696e..2a7231c2 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -24,6 +24,8 @@ mod alloc_release; mod appearance; +#[cfg(target_os = "linux")] +mod colord; mod control_server; mod dialog_filter; mod editors; @@ -525,7 +527,10 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> // macOS doesn't colour-manage the window: previews follow the screen's profile (#787) #[cfg(target_os = "macos")] screen_profile: Some(Box::new(screen_profile::icc)), - #[cfg(not(target_os = "macos"))] + // Linux: the profile the desktop assigns in colord, read off the UI thread + #[cfg(target_os = "linux")] + screen_profile: Some(colord::service()), + #[cfg(not(any(target_os = "macos", target_os = "linux")))] screen_profile: None, pick_display_profile: Some(Box::new(|| { let mut d = file_dialog() diff --git a/docs/display-profiles.md b/docs/display-profiles.md index 9d6b11af..67892a3c 100644 --- a/docs/display-profiles.md +++ b/docs/display-profiles.md @@ -8,17 +8,25 @@ colour-manage LightCraft's window (wgpu leaves its Metal layer without a colour unmatched), so on a P3 screen previews used to look more saturated than the exported files in Preview or Photos (issue #787). The profile is read again about once a second, so moving the window to another screen is followed. +**Linux uses the profile the desktop assigns in colord** (GNOME / KDE Settings ▸ Color, or a calibrator such as +DisplayCAL) while none is chosen. A background thread asks colord (`org.freedesktop.ColorManager` on the system bus) +every 3 s, so the UI never waits on D-Bus, and a profile change in the desktop's settings shows within that. eframe +doesn't say which monitor the window is on, so with several displays the profile is used only when all of them that +have one share it; otherwise, and without colord, previews stay sRGB (choose a profile by hand then). A compositor +that colour-manages windows itself with the same profile (KWin with an ICC profile set in Display Configuration) +would correct twice; KWin keeps that profile in its own settings, not colord, so this only happens when both are set. + ## 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** -(on macOS **Use Screen Profile**) goes back to no profile. The choice is an app setting (`ui.json`, `displayProfile`) +(on macOS and Linux **Use Screen Profile**) 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]}}`; the screen's -profile (macOS, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` -(`apps/lightcraft/src/screen_profile.rs`); headless sessions have none and stay sRGB. +profile (macOS or Linux, none chosen) reports `path: null, screen: true`. Hosts supply it as `Services::screen_profile` +(`apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`); headless sessions have none and stay sRGB. ## How it works @@ -47,5 +55,5 @@ LUT-based profile's correction about 65 ms. Drafts during slider drags skip the ## Not yet -- Picking up the profile the operating system assigns on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS) and - following the window across monitors there (macOS does both, above). +- Picking up the profile Windows assigns (WCS: needs FFI, so an isolated `unsafe` helper crate) and X11's + `_ICC_PROFILE`; following the window across monitors on Linux and Windows (macOS does both, above). diff --git a/docs/gaps.md b/docs/gaps.md index 49a15b1f..16270a11 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -38,6 +38,8 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | | 22 | **Pen pressure**, OS monitor profiles, follow-window colour | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) | +| 22 | **Pen pressure**, OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | +| 23 | **Accessibility** audit with screen readers; canvas tools keyboard-reachable | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 10–20 | [ui-parity](ui-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | | 25 | **File writes**: lossy DNG, Render to DNG; AVIF import (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 21–37 | [file-format-parity](file-format-parity.md) | | 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | @@ -75,6 +77,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed | +| 2026-10-12 | minor | Gap 22: Linux previews use the display profile from colord | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 3f1defc5..c2ad4a12 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (monitor profile from colord on Linux); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: [gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), @@ -12,7 +12,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | GPU export | ✅ | ✅ (6000 × 4000 in ~0.3 s vs ~1.2 s CPU) | ✅ | ✅ | — | ✅ (black-export fix on an Intel iGPU, #78) | | AI on GPU / neural engine | Metal / DirectML / Core ML, model per feature | AI denoise on GPU or CPU (setting); SAM 3 on Metal via candle; faces on CPU | CPU / GPU denoise | CPU / GPU denoise | — | 🟡 no Core ML / Neural Engine / DirectML | | HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | -| Monitor profile | automatic per display, follows the window | 🟡 picked by hand | 🟡 | 🟡 | — | 🟡 OS profile not read; no follow across monitors | +| Monitor profile | automatic per display, follows the window | ✅ the screen's profile, followed across screens | 🟡 picked by hand (WCS needs FFI) | 🟡 colord's profile (one display, or all sharing one); no follow across monitors | — | 🟡 Windows OS profile not read; Linux per-monitor follow missing | | Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | | Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | | Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | @@ -35,7 +35,7 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Work | Hours | Needs | |---|---:|---| | HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) | -| OS monitor profile, follow window across displays | 5–10 | multi-monitor setup | +| OS monitor profile: Windows WCS (an FFI helper crate, like `crates/sysmem`), follow the window across displays on Linux | 4–8 | multi-monitor setup | | Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | | Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | | | Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) | @@ -47,4 +47,5 @@ correctness is proven on Apple GPUs; other vendors rely on user reports. | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Monitor profile: Linux reads the display profile from colord; macOS row reflects #787 | | 2026-10-10 | major | Created | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 7bf604f2..0284f80f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -430,7 +430,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | 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`, `cmd:view.appearance`, `cmd:view.theme` | Appearance Mode (Sync with System / Dark / Light; the top bar button cycles Auto, Light, Dark) with separate dark (Charcoal, Midnight) and light (Silver, Paper) themes previewed in cards; hover stars persist favorite themes, the appearance button context menu opens Interface settings and lists light favorites before dark favorites; 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`, `apps/lightcraft/src/screen_profile.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787); 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 on Linux and Windows (X11 `_ICC_PROFILE`, colord, WCS), per-monitor profiles there | +| 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`, `apps/lightcraft/src/screen_profile.rs`, `apps/lightcraft/src/colord.rs`, `docs/display-profiles.md` | Settings ▸ Display: a monitor ICC profile (matrix/TRC exactly, LUT-based through the CMS) chosen by hand, or on macOS the profile of the screen showing the window (followed across screens, #787), on Linux the profile the desktop assigns in colord (read off the UI thread every 3 s; used with one display, or several sharing one profile); 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: Windows' WCS profile (needs FFI, so `unsafe`), X11 `_ICC_PROFILE`, per-monitor profiles on Linux (eframe doesn't say which monitor the window is on) | | LR-PREF-PERFORMANCE | GPU / performance | P1 | ✅ | `cmd:app.gpu`, `cmd:app.memoryBudget`, `cmd:app.settings` | GPU on/off (a crash while the GPU device or its kernels are built, issue #250, turns it off for the next launch), 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; the Recognise faces switch is off until a recognition model is chosen and installed (`faces.enable {enabled: true}` refuses otherwise; removing the last recogniser switches it off), issue #678 | | 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 | @@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-11 | minor | LR-IMP-PREVIEW-READ: a preview reads the raw's header and its embedded JPEG instead of the whole file (a 56 MB RAF: 56.5 MB → 5.9 MB, ~10 s → 0.96 s over SMB, byte-identical pixels), and an unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is queued behind it. A CR3's JPEG track and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads; the formats it cannot describe (CRW/MRW/X3F, NEF/ORF/PEF) keep the whole-file path | | 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | +| 2026-10-12 | minor | LR-PREF-DISPLAY: on Linux, previews follow the display profile the desktop assigns in colord | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |