mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 11:17:05 -04:00
Merge PR #791
# Conflicts: # Cargo.lock # apps/lightcraft/src/main.rs # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
12 files changed
+198
-12
No files matched your search
Generated
+3
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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"))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
@@ -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)"}),
|
||||
})
|
||||
|
||||
@@ -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).
|
||||
@@ -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 | ⬜ | | |
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user