From d7eb6b2ab4289a2c0d03981f4f86c2c0e8bee5b7 Mon Sep 17 00:00:00 2001 From: lloyd Date: Sat, 10 Oct 2026 19:51:01 +0200 Subject: [PATCH] LR-PREF-DISPLAY: macOS previews follow the screen's profile, so they match exports (#787) Previews looked more saturated than the exported files on Mac screens. The pixels are the same: preview and export match to within one level on the CPU and GPU paths, and exports embed a correct sRGB profile. But wgpu leaves the window's Metal layer without a colour space for sRGB surfaces, and Core Animation shows such a layer without colour matching ("If nil, no colormatching occurs", CAMetalLayer.h). On a P3 screen the sRGB previews were therefore shown too saturated, while Preview and Photos convert the exports properly. While no monitor profile is chosen in Settings > Display, the desktop app now shows previews through the profile of the screen the window is on (NSWindow.screen.colorSpace.ICCProfileData, safe objc2 calls), through the existing display-profile path. It is read again about once a second, so moving the window to another screen is followed. A chosen profile still wins; "Use Screen Profile" goes back to it. app.displayProfile reports it as path: null, screen: true. Other platforms and headless sessions are unchanged. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 ++ Cargo.toml | 4 +++ apps/lightcraft-web/src/web.rs | 1 + apps/lightcraft/Cargo.toml | 3 ++ apps/lightcraft/src/main.rs | 7 ++++ apps/lightcraft/src/screen_profile.rs | 30 ++++++++++++++++ crates/ui-egui/src/headless.rs | 50 ++++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 51 +++++++++++++++++++++++++++ crates/ui-egui/src/panels/settings.rs | 35 ++++++++++++++---- docs/display-profiles.md | 17 ++++++--- docs/parity.md | 2 +- docs/whats-new.md | 6 ++++ 12 files changed, 196 insertions(+), 13 deletions(-) create mode 100644 apps/lightcraft/src/screen_profile.rs diff --git a/Cargo.lock b/Cargo.lock index 0ad4ab843..813afecdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2987,6 +2987,9 @@ dependencies = [ "lightcraft-ui-egui", "log", "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "rfd", "serde_json", "winresource", diff --git a/Cargo.toml b/Cargo.toml index 58e5383b7..ddd1c138f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,10 @@ eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" rfd = "0.17" muda = { version = "0.21", default-features = false } # native menu bar (macOS: objc2/AppKit, pure Rust; no GTK) +# macOS: the profile of the screen showing the window (safe AppKit bindings; muda and wgpu use the same) +objc2 = { version = "0.6", default-features = false, features = ["std"] } +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSColorSpace", "NSResponder", "NSScreen", "NSWindow"] } +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSData"] } web-time = "1" # web (apps/lightcraft-web) diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 592d05cd1..69a1644fd 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -170,6 +170,7 @@ fn services(originals: Originals, backend: Option, files: Files, frozen Services { picker: None, pick_display_profile: None, + screen_profile: None, backup_library: Some(Box::new(move |session: &mut Session| { let Some(b) = backup_backend.clone() else { return Err("nothing is stored in this browser session (?store=memory)".into()) }; // the photos' own names for the originals in the zip diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index e4d7f2b3b..db9b6582c 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -37,3 +37,6 @@ rfd = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] muda = { workspace = true } +objc2 = { workspace = true } +objc2-app-kit = { workspace = true } +objc2-foundation = { workspace = true } diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 00665e87e..7712b1b43 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -28,6 +28,8 @@ mod dialog_filter; mod logging; #[cfg(target_os = "macos")] mod native_menu; +#[cfg(target_os = "macos")] +mod screen_profile; use dialog_filter::FileDialogExt as _; use lightcraft_engine::Session; @@ -489,6 +491,11 @@ fn services(ctx: egui::Context, log_file: Option<&std::path::Path>) -> Services .map(|p| vec![p.to_string_lossy().to_string()]) .unwrap_or_default() })), + // 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"))] + screen_profile: None, pick_display_profile: Some(Box::new(|| { let mut d = rfd::FileDialog::new() .set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile")) diff --git a/apps/lightcraft/src/screen_profile.rs b/apps/lightcraft/src/screen_profile.rs new file mode 100644 index 000000000..c787d6989 --- /dev/null +++ b/apps/lightcraft/src/screen_profile.rs @@ -0,0 +1,30 @@ +//! The ICC profile of the screen showing LightCraft's window (macOS). +//! +//! macOS colour-manages windows that say what colour space they draw in. wgpu leaves the window's +//! Metal layer without one for sRGB content, which Core Animation shows without colour matching, so +//! on a wide-gamut (P3) screen the previews looked more saturated than the exported files do in +//! Preview or Photos (issue #787). While no monitor profile is chosen in Settings ▸ Display, the +//! previews are shown through this profile instead (`Services::screen_profile`). + +use objc2::MainThreadMarker; +use objc2_app_kit::{NSApplication, NSScreen}; + +/// The profile of the screen the window is mostly on (the main screen when it is hidden), or `None` +/// when it isn't known: off the main thread, or a screen without a usable profile. +pub fn icc() -> Option> { + let mtm = MainThreadMarker::new()?; + let app = NSApplication::sharedApplication(mtm); + let window = app.mainWindow().or_else(|| app.keyWindow()).or_else(|| app.windows().firstObject()); + let screen = window.and_then(|w| w.screen()).or_else(|| NSScreen::mainScreen(mtm))?; + let icc = screen.colorSpace()?.ICCProfileData()?.to_vec(); + (!icc.is_empty()).then_some(icc) +} + +#[cfg(test)] +mod tests { + #[test] + fn unknown_off_the_main_thread() { + // AppKit is main-thread only; a test thread gets nothing rather than undefined behaviour + assert_eq!(std::thread::spawn(super::icc).join().unwrap(), None); + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index d0104f7de..3aa0d4e1a 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -3114,6 +3114,56 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// With no profile chosen, previews follow the profile of the screen showing the window (the + /// host's `screen_profile`: macOS doesn't colour-manage the window, issue #787), read again so + /// a move to another screen is followed. A chosen profile wins; choosing none goes back to the + /// screen's at once; a screen without a profile is sRGB. + #[test] + fn previews_follow_the_screen_profile_while_none_is_chosen() { + use lightcraft_codecs::{NamedSpace, icc::write_named}; + use std::sync::{Arc, Mutex}; + let id = |space| lightcraft_engine::display::Display::from_bytes("", &write_named(space)).unwrap().id(); + let dir = std::env::temp_dir().join(format!("lightcraft-screen-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let chosen = dir.join("prophoto.icc"); + std::fs::write(&chosen, write_named(NamedSpace::ProPhoto)).unwrap(); + let chosen = chosen.to_string_lossy().to_string(); + + let screen = Arc::new(Mutex::new(Some(write_named(NamedSpace::DisplayP3)))); + let mut h = demo([1000.0, 700.0]); + let read = screen.clone(); + h.app.services.screen_profile = Some(Box::new(move || read.lock().unwrap().clone())); + let t = Duration::from_secs(10); + h.step(); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3)), "the screen's profile"); + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {}}), t); + assert_eq!((r["result"]["screen"].clone(), r["result"]["path"].clone()), (json!(true), Value::Null), "{r}"); + assert!(h.app.ui.settings.display_profile.is_empty(), "nothing is saved as the setting"); + + // the window moves to another screen + *screen.lock().unwrap() = Some(write_named(NamedSpace::AdobeRgb)); + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id() == Some(id(NamedSpace::AdobeRgb)))); + + // a chosen profile wins over the screen's + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": chosen}}), t); + assert_eq!(r["ok"], true, "{r}"); + *screen.lock().unwrap() = Some(write_named(NamedSpace::DisplayP3)); + for _ in 0..90 { + h.step(); + } + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::ProPhoto))); + + // none chosen again: the screen's profile, at once + let r = h.request("engine.execute", json!({"command": "app.displayProfile", "params": {"path": null}}), t); + assert_eq!(r["result"]["screen"], true, "{r}"); + assert_eq!(h.app.renderer.display_id(), Some(id(NamedSpace::DisplayP3))); + + // a screen without a profile: sRGB + *screen.lock().unwrap() = None; + assert!(h.step_until(SETTLE, |h| h.app.renderer.display_id().is_none())); + 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] diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6b5033e0c..9e752bb82 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -102,6 +102,8 @@ pub use control::{ControlRequest, ControlResponse}; pub use state::UiState; const TOAST_SECONDS: f64 = 1.4; +/// The `path` of a display profile read from the screen rather than chosen as a file. +pub const SCREEN_PROFILE: &str = "screen"; pub type PickFiles = Box Vec>; /// A save dialog: suggested file name → chosen path (`None` = cancelled). @@ -118,6 +120,9 @@ pub type RevealFn = Box Result<(), String>>; pub type OpenUrlFn = Box Result<(), String>>; /// Open a file in an application (`app` = "" for the system's default one). pub type OpenWithFn = Box Result<(), String>>; +/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`: +/// not known). +pub type ScreenProfileFn = Box Option>>; /// Something the host does with the session (web: back up or restore the library in browser /// storage); the work may finish asynchronously. pub type HostAction = Box Result>; @@ -141,6 +146,9 @@ pub struct Services { pub pick_tracklog: Option, /// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only). pub pick_display_profile: Option, + /// The profile of the screen showing the window, used while none is chosen in Settings ▸ + /// Display (macOS, whose windows aren't colour-managed: issue #787). + pub screen_profile: Option, /// Save dialog for an exported `.lcpreset` file. pub save_preset_file: Option, /// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…). @@ -319,6 +327,10 @@ pub struct LightcraftApp { pub(crate) display_applied: Option, /// Why the display profile setting could not be applied (shown in Settings ▸ Display). pub display_error: Option, + /// The screen profile in use while no display profile is chosen (`services.screen_profile`), + /// and when it was last read (egui time, s). + pub(crate) screen_profile: Option>, + pub(crate) screen_profile_read: Option, /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, @@ -388,6 +400,8 @@ impl LightcraftApp { memory_applied: None, display_applied: None, display_error: None, + screen_profile: None, + screen_profile_read: None, library_problem: None, activity_expanded: false, model_setup: Default::default(), @@ -946,6 +960,12 @@ impl LightcraftApp { } } self.display_applied = Some(path); + // none chosen again: the screen's profile is read afresh + self.screen_profile = None; + self.screen_profile_read = None; + } + if self.ui.settings.display_profile.is_empty() { + self.follow_screen_profile(ctx); } if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) { self.window_is_fullscreen = fs; @@ -956,6 +976,37 @@ impl LightcraftApp { } } + /// While no display profile is chosen, show previews through the profile of the screen the + /// window is on (`services.screen_profile`), read about once a second so moving the window to + /// another screen is followed. Without the service (or a profile) the display is sRGB. + fn follow_screen_profile(&mut self, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if self.services.screen_profile.is_none() || self.screen_profile_read.is_some_and(|t| (0.0..1.0).contains(&(now - t))) { + return; + } + self.screen_profile_read = Some(now); + self.apply_screen_profile(); + } + + /// Read the screen's profile now and show previews through it (sRGB without one). + pub(crate) fn apply_screen_profile(&mut self) { + let Some(read) = self.services.screen_profile.as_mut() else { return }; + let icc = read(); + if icc == self.screen_profile { + return; + } + let display = icc.as_deref().and_then(|bytes| match lightcraft_engine::display::Display::from_bytes(SCREEN_PROFILE, bytes) { + Ok(d) => Some(std::sync::Arc::new(d)), + Err(msg) => { + // a profile we can't use: previews stay sRGB, as before #787 + log::warn!("screen profile: {msg}"); + None + } + }); + self.renderer.set_display(display); + self.screen_profile = icc; + } + /// Inject synthetic events (pointer events one per frame; key sequences up to the release). pub fn raw_input_hook(&mut self, raw: &mut egui::RawInput) { // the frame after a synthetic release / key: modifiers back up diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index 7036e10f1..0b1613f02 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -540,12 +540,16 @@ fn smart_previews(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { 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(); + let screen = current.as_ref().is_some_and(|d| d.path == crate::SCREEN_PROFILE); 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)); + if screen { + hint(ui, t, crate::i18n::tr("this screen's profile")); + } }); row(ui, t, "", |ui| { let can = app.services.pick_display_profile.is_some(); @@ -556,7 +560,9 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { { 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"))); + // with no profile chosen, previews follow the screen's own profile where the host knows it + let none = if app.services.screen_profile.is_some() { crate::i18n::tr("Use Screen Profile") } else { crate::i18n::tr("Use sRGB") }; + let r = ui.add_enabled(!app.ui.settings.display_profile.is_empty(), egui::Button::new(none)); register(ui.ctx(), "button:settingsDisplayProfileNone", r.rect); if r.clicked() { app.ui.settings.display_profile.clear(); @@ -570,9 +576,11 @@ fn display_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } 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)); - }); + if !screen { + 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"), @@ -654,9 +662,11 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result // ---------------------------------------------------------------------------- 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]}}`. +/// (`""` or `null`: none, the profile of the screen showing the window where the host knows it +/// (macOS), else 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]}}`, with +/// `path: null, screen: true` for the screen's profile. pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { let path = match p.get("path") { None => None, @@ -670,8 +680,19 @@ pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result { + let mut v = d.describe(); + v["path"] = Value::Null; + v["screen"] = json!(true); + v + } Some(d) => d.describe(), None => json!({"path": null, "description": "sRGB (no display profile)"}), }) diff --git a/docs/display-profiles.md b/docs/display-profiles.md index 63c557108..9d6b11af7 100644 --- a/docs/display-profiles.md +++ b/docs/display-profiles.md @@ -3,15 +3,22 @@ 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. +**macOS uses the screen's own profile** (the one System Settings ▸ Displays assigns) while none is chosen. macOS doesn't +colour-manage LightCraft's window (wgpu leaves its Metal layer without a colour space, which Core Animation shows +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. + ## 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. +(on macOS **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]}}`. +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. ## How it works @@ -40,5 +47,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 (X11 `_ICC_PROFILE`, colord, ColorSync, WCS) and following - the window across monitors with different profiles. +- 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). diff --git a/docs/parity.md b/docs/parity.md index d0bdc35c2..292f1f0e8 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -490,7 +490,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | 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-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-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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb1fd3165..671556e63 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,12 @@ ## October 2026 +### Previews match exports on Mac screens +- On macOS, photos are shown through the profile of the screen the window is on while no monitor profile is chosen in + Settings ▸ Display (issue #787). On P3 screens previews used to look more saturated than the exported files in + Preview or Photos. Moving the window to another screen is followed; **Use Screen Profile** goes back to it after + choosing a profile file. + ### Activity stack - Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,