# Conflicts:
#	Cargo.lock
#	apps/lightcraft/src/main.rs
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Claude committed 2026-10-11 12:01:19 +00:00
commit 76fc27a7bc
12 files changed
+198 -12

No files matched your search

Generated
+3
View File
@@ -3078,6 +3078,9 @@ dependencies = [
"lightcraft-ui-egui",
"log",
"muda",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"pollster 1.0.1",
"rfd",
"serde_json",
+4
View File
@@ -91,6 +91,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)
+1
View File
@@ -170,6 +170,7 @@ fn services(originals: Originals, backend: Option<Backend>, 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
+3
View File
@@ -39,3 +39,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 }
+7
View File
@@ -28,6 +28,8 @@ mod dialog_filter;
mod logging;
#[cfg(target_os = "macos")]
mod native_menu;
#[cfg(target_os = "macos")]
mod screen_profile;
mod window_gpu;
use dialog_filter::FileDialogExt as _;
@@ -538,6 +540,11 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path>
.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 = file_dialog()
.set_title(lightcraft_ui_egui::i18n::tr("Choose Monitor Profile"))
+30
View File
@@ -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<Vec<u8>> {
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);
}
}
+50
View File
@@ -3217,6 +3217,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]
+51
View File
@@ -126,6 +126,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<dyn FnMut() -> Vec<String>>;
/// A save dialog: suggested file name → chosen path (`None` = cancelled).
@@ -142,6 +144,9 @@ pub type RevealFn = Box<dyn FnMut(&str) -> Result<(), String>>;
pub type OpenUrlFn = Box<dyn FnMut(&str) -> Result<(), String>>;
/// Open a file in an application (`app` = "" for the system's default one).
pub type OpenWithFn = Box<dyn FnMut(&str, &str) -> Result<(), String>>;
/// The ICC profile of the screen showing the window, as the system colour-manages it (`None`:
/// not known).
pub type ScreenProfileFn = Box<dyn FnMut() -> Option<Vec<u8>>>;
/// 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<dyn FnMut(&mut Session) -> Result<Value, String>>;
@@ -165,6 +170,9 @@ pub struct Services {
pub pick_tracklog: Option<PickFiles>,
/// Open dialog for a monitor ICC profile (`.icc` / `.icm`; Settings ▸ Display; desktop only).
pub pick_display_profile: Option<PickFiles>,
/// 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<ScreenProfileFn>,
/// Save dialog for an exported `.lcpreset` file.
pub save_preset_file: Option<SaveFile>,
/// Open dialog for a keyword list file (`.txt`, Photo Supreme's `.utf8`; File ▸ Import Keywords…).
@@ -355,6 +363,10 @@ pub struct LightcraftApp {
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 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<Vec<u8>>,
pub(crate) screen_profile_read: Option<f64>,
/// 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>,
@@ -429,6 +441,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(),
@@ -1017,6 +1031,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;
@@ -1027,6 +1047,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
+28 -7
View File
@@ -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 &current {
Some(d) => d.profile.description.clone(),
None => crate::i18n::tr("None (the display is treated as sRGB)").to_string(),
};
ui.label(RichText::new(name).color(t.text));
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) = &current {
let info = d.describe();
row(ui, t, crate::i18n::tr("File"), |ui| {
ui.label(RichText::new(&d.path).size(11.0).color(t.text_dim));
});
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"),
@@ -671,9 +679,11 @@ pub fn open_library(app: &mut LightcraftApp, p: &Value) -> Result<Value, String>
// ---------------------------------------------------------------------------- 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<Value, String> {
let path = match p.get("path") {
None => None,
@@ -687,8 +697,19 @@ pub fn display_profile(app: &mut LightcraftApp, p: &Value) -> Result<Value, Stri
app.ui.settings.display_profile = path.clone();
app.display_applied = Some(path);
app.display_error = None;
app.screen_profile = None;
app.screen_profile_read = None;
if app.ui.settings.display_profile.is_empty() {
app.apply_screen_profile();
}
}
Ok(match app.renderer.display() {
Some(d) if d.path == crate::SCREEN_PROFILE => {
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)"}),
})
+12 -5
View File
@@ -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).
+3
View File
@@ -426,8 +426,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
| 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 (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-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; 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-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 |
| LR-PREF-SHORTCUTS | Shortcut customisation | — | ✅ | `cmd:app.setShortcut`, `cmd:app.resetShortcuts`, `crates/ui-egui/src/panels/keymap.rs` | an extra (the reference app has no keymap editor): Help ▸ Keyboard Shortcuts (⌘/) lists every command; click a shortcut and press the new keys (Esc cancels), × removes, ↺ restores, Reset All; a key another command had moves over; saved in `ui.json`; native menu accelerators follow. Rating/label digits and secondary keys are listed read-only and yield to a key given to a command |
| LR-PREF-TECHPREVIEW | Early-access toggles | P2 | ⬜ | | |
+6
View File
@@ -21,6 +21,12 @@
buttons stay visible. Long lists in dialogs (What's New, All Metadata, Keyboard Shortcuts) get shorter to fit.
Dialogs that fit look as before.
### 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,